From 670b04897eedd62e96147a5209dbd66d975773e2 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 21:08:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8E=88=E6=9D=83=E5=87=AD=E8=AF=81=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=BB=AD=E6=9C=9F=EF=BC=8C=E5=BA=94=E7=94=A8=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=BF=94=E5=9B=9E=E5=AE=8C=E6=95=B4=E5=87=AD?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 117 ++- go/auth.go | 210 ++-- go/auth_empty_access_token_test.go | 30 +- go/auth_expiresat_validation_test.go | 58 +- go/auth_result_stringer_redaction_test.go | 120 ++- go/auth_stale_expiresat_test.go | 44 +- go/auth_test.go | 197 ++-- ... => auth_user_access_token_fields_test.go} | 56 +- go/client.go | 52 +- go/doc.go | 2 +- go/errors.go | 8 + go/genai.go | 22 +- go/generated/types.gen.go | 30 +- go/island.go | 11 +- go/mark.go | 44 +- go/spu.go | 22 +- go/tag.go | 22 +- go/tokenstore.go | 22 +- go/user.go | 9 +- go/usercredential.go | 380 +++++++ go/usercredential_test.go | 935 ++++++++++++++++++ go/wishspu.go | 33 +- .../qdmp/generated/AuthTokenRequest.java | 6 +- .../qdmp/generated/KnownErrorCodes.java | 2 +- .../echotechfe/qdmp/AccessTokenSource.java | 44 + .../io/github/echotechfe/qdmp/QdmpClient.java | 29 +- .../github/echotechfe/qdmp/QdmpContext.java | 30 +- .../github/echotechfe/qdmp/QdmpTransport.java | 122 ++- .../echotechfe/qdmp/QdmpUserClient.java | 381 +++++++ .../qdmp/StaticAccessTokenSource.java | 30 + .../qdmp/auth/AppAccessTokenResult.java | 107 ++ .../echotechfe/qdmp/auth/CachedAppToken.java | 45 +- .../github/echotechfe/qdmp/auth/QdmpAuth.java | 119 ++- .../qdmp/auth/RefreshedUserToken.java | 56 ++ .../echotechfe/qdmp/auth/TokenStore.java | 14 +- ...Result.java => UserAccessTokenResult.java} | 14 +- .../qdmp/auth/UserCredentialOptions.java | 170 ++++ .../qdmp/auth/UserCredentialSession.java | 131 +++ .../qdmp/auth/UserCredentialSnapshot.java | 69 ++ .../qdmp/errors/QdmpValidationError.java | 12 +- .../echotechfe/qdmp/groups/GenaiGroup.java | 4 +- .../echotechfe/qdmp/groups/IslandGroup.java | 2 +- .../echotechfe/qdmp/groups/MarkGroup.java | 8 +- .../echotechfe/qdmp/groups/SpuGroup.java | 4 +- .../echotechfe/qdmp/groups/TagGroup.java | 4 +- .../echotechfe/qdmp/groups/UserGroup.java | 2 +- .../echotechfe/qdmp/groups/WishSpuGroup.java | 6 +- .../QdmpTransportResponseSizeLimitTest.java | 13 +- .../qdmp/auth/InMemoryTokenStoreTest.java | 7 +- .../auth/QdmpAuthExpiresAtValidationTest.java | 23 +- ...ava => QdmpAuthGetAppAccessTokenTest.java} | 104 +- .../qdmp/auth/QdmpAuthResultTypesTest.java | 33 +- ...dmpAuthUserAccessTokenAndRefreshTest.java} | 48 +- ...thUserAccessTokenFieldValidationTest.java} | 22 +- .../qdmp/auth/QdmpUserCredentialTest.java | 703 +++++++++++++ .../errors/QdmpApiErrorSanitizationTest.java | 4 +- node/src/auth.ts | 149 +-- node/src/client.ts | 26 + node/src/generated/error-codes.ts | 4 +- node/src/generated/schema.d.ts | 38 +- node/src/http.ts | 79 ++ node/src/index.ts | 7 + node/src/redact.ts | 29 + node/src/token-store.ts | 9 + node/src/types.ts | 20 +- node/src/user-client.ts | 119 +++ node/src/user-credential.ts | 192 ++++ node/test/auth-expired-fresh-token.test.ts | 14 +- node/test/auth-malformed-success.test.ts | 26 +- .../auth-result-inspect-redaction.test.ts | 54 +- .../auth-tokenstore-write-failure.test.ts | 8 +- ... => auth-user-access-token-fields.test.ts} | 46 +- node/test/auth.test.ts | 112 ++- node/test/errors-code-sanitization.test.ts | 6 +- node/test/errors-message-sanitization.test.ts | 4 +- node/test/helpers/mock-http.ts | 35 + node/test/user-credential.test.ts | 915 +++++++++++++++++ shared/generated/error-codes.json | 4 +- shared/openapi.yaml | 16 +- 79 files changed, 5873 insertions(+), 831 deletions(-) rename go/{auth_code2session_fields_test.go => auth_user_access_token_fields_test.go} (53%) create mode 100644 go/usercredential.go create mode 100644 go/usercredential_test.go create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java rename java/src/main/java/io/github/echotechfe/qdmp/auth/{Code2SessionResult.java => UserAccessTokenResult.java} (87%) create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java create mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java rename java/src/test/java/io/github/echotechfe/qdmp/auth/{QdmpAuthGetAccessTokenTest.java => QdmpAuthGetAppAccessTokenTest.java} (74%) rename java/src/test/java/io/github/echotechfe/qdmp/auth/{QdmpAuthCodeSessionAndRefreshTest.java => QdmpAuthUserAccessTokenAndRefreshTest.java} (82%) rename java/src/test/java/io/github/echotechfe/qdmp/auth/{QdmpAuthCode2SessionFieldValidationTest.java => QdmpAuthUserAccessTokenFieldValidationTest.java} (78%) create mode 100644 java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java create mode 100644 node/src/redact.ts create mode 100644 node/src/user-client.ts create mode 100644 node/src/user-credential.ts rename node/test/{auth-session-fields.test.ts => auth-user-access-token-fields.test.ts} (51%) create mode 100644 node/test/user-credential.test.ts diff --git a/README.md b/README.md index 17617e1..faf6212 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,30 @@ # 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) 三端实现,统一凭证生命周期管理 + 类型安全的业务接口封装。 + +## 两种凭证 + +OpenAPI 的 token 都由 Auth 服务的 `POST /auth/v1/token` 签发,按使用场景分两种: + +| | 应用凭证 | 用户授权凭证 | +|---|---|---| +| `grantType` | `CLIENT_CREDENTIALS` | `AUTHORIZATION_CODE` | +| 代表谁 | 这个开放平台应用,不代表任何用户 | 某个真实登录用户,绑定 `openId` | +| 用在哪 | 开发者内部调试、服务端联调、不需要用户身份的后台任务 | 应用上线后代表用户调业务接口——**日常调用全程用它** | +| 怎么拿 | `appId` + `appSecret` 直接换 | 先拿一次性授权码,再用 `appId` + `appSecret` + `code` 换 | +| 有效期 | accessToken 7200 秒 | accessToken 7200 秒,refreshToken 2592000 秒(30 天) | + +应用凭证换不到用户身份,也调不了需要校验用户授权的接口,别拿它顶替用户授权凭证。 + +## 完整流程 + +用户授权凭证要两步换,第一步在前端、第二步在你的服务端: + +1. **前端/App 拿一次性授权码**:持用户登录态请求 `POST https://api.qiandao.com/signin/openapi/auth/generate-code`, + Header 带 `Authorization: Bearer <用户登录态 token>`,Body 传 `{"appId": "app_xxx", "scene": "openapi"}`, + 拿到 `data.code`。这一步需要用户登录态,本 SDK 不封装。 +2. **服务端换用户授权凭证**:把 `code` 传给下面的 `getUserAccessToken`。授权码是一次性的,**5 分钟内有效**,被 Auth 消费后立即失效。 +3. **调业务接口**:用换到的 accessToken,过期前 SDK 自动拿 refreshToken 续期。 ## Node.js @@ -16,21 +40,29 @@ const qdmp = new QdmpClient({ appSecret: process.env.QDMP_APP_SECRET!, }) -// 小程序端 wx.login() 拿到 code 后,后端用它换用户 session(不缓存,自己持久化) -const session = await qdmp.auth.code2Session(code) +// 前端传来一次性授权码,服务端换用户授权凭证并落库 +const credential = await qdmp.auth.getUserAccessToken(code) +await db.saveCredential(credential.openId, credential) + +// 绑定用户授权凭证,之后业务调用不用再传 token;到期前自动续期,续期后回写你的库 +const asUser = qdmp.withUserCredential({ + ...credential, + onRefresh: token => db.updateCredential(credential.openId, token), +}) -// 用户级 accessToken 调业务接口,ctx.accessToken 必填 -const me = await qdmp.user.me({ accessToken: session.accessToken }) +const me = await asUser.user.me() +await asUser.mark.add({ spuId: '123', rating: { value: 5 } }) try { - await qdmp.mark.add({ accessToken: session.accessToken }, { spuId: '123', rating: { value: 5 } }) + await asUser.wishspu.list({ 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`(本地参数校验失败,不发请求)。 @@ -50,17 +82,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); +db.saveCredential(credential.getOpenId(), credential); -// QdmpContext.of(accessToken) 是唯一入口,空/非法 token 在构造期就失败 -QdmpContext ctx = QdmpContext.of(session.getAccessToken()); -UserMe200ResponseAllOfData me = qdmp.user().me(ctx); +QdmpUserClient asUser = qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken(credential.getAccessToken()) + .refreshToken(credential.getRefreshToken()) + .expiresAt(credential.getExpiresAtEpochSeconds()) + .onRefresh(token -> db.updateCredential(credential.getOpenId(), token)) + .build()); -qdmp.mark().add(ctx, new MarkAddRequest().spuId("123")); +UserMe200ResponseAllOfData me = asUser.user().me(); +asUser.mark().add(new MarkAddRequest().spuId("123")); -// app 级 token 显式换取 -String appToken = qdmp.auth().getAccessToken(); +AppAccessTokenResult appCredential = qdmp.auth().getAppAccessToken(); ``` 业务失败抛 `QdmpApiError`,传输层异常抛 `QdmpTransportException`,本地参数校验失败抛 `QdmpValidationError`(均在 `io.github.echotechfe.qdmp.errors` 包下)。 @@ -77,36 +113,51 @@ 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) +db.SaveCredential(credential.OpenID, credential) + +asUser := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: credential.AccessToken, + RefreshToken: credential.RefreshToken, + ExpiresAt: credential.ExpiresAt, + OnRefresh: func(ctx context.Context, t qdmp.RefreshedUserToken) error { + return db.UpdateCredential(credential.OpenID, t) + }, +}) -// WithAccessToken 派生一个绑定了该 token 的子 client,拿不到 token 就构造不出可用的调用入口 -asUser := client.WithAccessToken(session.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) +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`。 +- **用户授权凭证**(主线):`withUserCredential` 绑定一份凭证后,SDK 到期前 300 秒自动用 refreshToken 续期; + 业务调用撞上 HTTP 401 + `10005`/`10006`(access_token 失效/过期)时,续期一次并重试该请求一次。 + 续期成功后回调 `onRefresh`,把新 accessToken 落到你自己的库里——SDK 不替你持久化,一个进程服务海量终端用户, + 只有你的 session/DB 知道这次调用属于哪个用户。同一份凭证上的并发调用只会触发一次续期。 +- **续期不换 refreshToken**:`/auth/v1/refresh` 只返回新的 accessToken 和 expiresAt,refreshToken 保持不变。 + 它本身失效时返回 HTTP 200 + `10007`/`10008`,此时凭证已无法挽救,需要重新走一次「拿授权码 → 换凭证」。 +- **应用凭证**:`getAppAccessToken()` 自动缓存 + 到期前 300 秒重新换取 + 单飞锁防并发重复换取, + 持久化走可插拔的 `TokenStore`(默认内存实现,多实例部署可换 Redis 等共享存储)。 + 它返回的是完整凭证 `{accessToken, expiresAt, refreshToken, openId}`——应用凭证响应里同样带 refreshToken, + 只是 `openId` 恒为空串。SDK 自己不拿它续期(到期直接重换),但如实交给你,要不要自己续期由你决定。 +- **成功判定只看响应体 `code === '0'`,不看 HTTP 状态码**——refreshToken 过期是 HTTP 200 + `10008`, + access_token 失效才是 HTTP 401 + `10006`。 +- 除上面那次 401 续期重试外,SDK 不做任何自动重试。 -## 各分组 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..b0d54ee 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. +// UserAccessTokenResult is the one-shot AUTHORIZATION_CODE exchange result. // The SDK never caches this — the caller's own session/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,22 +391,22 @@ 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. @@ -326,7 +414,7 @@ func (a *AuthService) Code2Session(ctx context.Context, code string) (*Code2Sess // would let a caller persist (or start relying on) a permanently-broken // session, 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 +445,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..0dfc918 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,10 +26,10 @@ 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{ "accessToken": "tok", @@ -40,19 +40,19 @@ 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{ "accessToken": "tok", @@ -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..487eb2b 100644 --- a/go/client.go +++ b/go/client.go @@ -1,6 +1,8 @@ package qdmp import ( + "context" + "encoding/json" "fmt" "net" "net/http" @@ -22,7 +24,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 +77,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,9 +193,14 @@ 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. +// A UserClient derived from WithUserCredential additionally carries a +// userCredential, which renews the access token on its own (see +// usercredential.go); one derived from WithAccessToken has a nil cred and +// keeps its static, never-renewed token. type UserClient struct { client *Client accessToken string + cred *userCredential User *UserGroup Island *IslandGroup @@ -224,15 +231,50 @@ func (c *Client) WithAccessToken(token string) *UserClient { // group method performs before touching the network: shared/generated/ // route-meta.json marks every non-auth operation x-qdmp-token-required=true, // so an empty token here always means "must not call this operation". +// +// For a WithUserCredential client this checks the credential's *current* +// access token (which a renewal may have replaced since construction), and +// additionally requires the refresh token that makes renewal possible at all. func requireAccessToken(uc *UserClient, opName string) error { - if uc.accessToken == "" { + current := uc.Credential() + if current.AccessToken == "" { return fmt.Errorf("qdmp: %s requires an access token: %w", opName, ErrAccessTokenRequired) } - for i := 0; i < len(uc.accessToken); i++ { - b := uc.accessToken[i] + for i := 0; i < len(current.AccessToken); i++ { + b := current.AccessToken[i] if b <= 0x1F || b == 0x7F { return fmt.Errorf("qdmp: %s: %w", opName, ErrInvalidAccessToken) } } + if uc.cred != nil && current.RefreshToken == "" { + return fmt.Errorf("qdmp: %s requires a refresh token: %w", opName, ErrRefreshTokenRequired) + } return nil } + +// doRequest is the single transport chokepoint every business group method +// goes through. Routing all seven groups here (instead of letting each call +// Client.doRequest directly) is what makes token renewal and the 401 retry +// a property of the client rather than something each operation has to +// remember to implement. +func (uc *UserClient) doRequest(ctx context.Context, p requestParams) (json.RawMessage, error) { + if uc.cred == nil { + p.accessToken = uc.accessToken + return uc.client.doRequest(ctx, p) + } + return uc.cred.do(ctx, p) +} + +// Credential returns a snapshot of the credential this client currently +// holds, so a caller can persist a renewed access token before its process +// exits. The returned value is an independent copy: it never changes as the +// client renews, and changing it never affects the client. +// +// For a WithAccessToken client (no renewal, no refresh token) only +// AccessToken is populated. +func (uc *UserClient) Credential() UserCredential { + if uc.cred == nil { + return UserCredential{AccessToken: uc.accessToken} + } + return uc.cred.snapshot() +} 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/errors.go b/go/errors.go index c6ad592..e16f267 100644 --- a/go/errors.go +++ b/go/errors.go @@ -20,6 +20,14 @@ var ErrAccessTokenRequired = errors.New("qdmp: access token is required") // returned. var ErrInvalidAccessToken = errors.New("qdmp: access token contains a character that cannot be safely sent as an HTTP header value") +// ErrRefreshTokenRequired is a sentinel error returned when a client derived +// from WithUserCredential was given an empty RefreshToken. Renewal is the +// whole point of that constructor and is impossible without one, so this +// fails locally on the first business call, before any HTTP request — a +// caller that genuinely has only an access token wants WithAccessToken +// instead. +var ErrRefreshTokenRequired = errors.New("qdmp: refresh token is required") + // QdmpApiError represents a business-level failure reported by the qdmp // OpenAPI, as opposed to a transport-level failure (network error, etc). // diff --git a/go/genai.go b/go/genai.go index 66864c6..376f172 100644 --- a/go/genai.go +++ b/go/genai.go @@ -38,12 +38,11 @@ func (g *GenAIGroup) Generate(ctx context.Context, body generated.GenaiGenerateJ return nil, err } - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/genai/v1/generate", - jsonBody: body, - authScheme: authSchemeGenai, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/genai/v1/generate", + jsonBody: body, + authScheme: authSchemeGenai, }) if err != nil { return nil, err @@ -70,12 +69,11 @@ func (g *GenAIGroup) Detail(ctx context.Context, params generated.GenaiDetailPar query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/genai/v1/detail", - query: query, - authScheme: authSchemeGenai, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/genai/v1/detail", + query: query, + authScheme: authSchemeGenai, }) if err != nil { return nil, err diff --git a/go/generated/types.gen.go b/go/generated/types.gen.go index cc72f7c..3ee023b 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 一次性授权码,由前端/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/island.go b/go/island.go index 9ee945d..e9cb6ee 100644 --- a/go/island.go +++ b/go/island.go @@ -41,12 +41,11 @@ func (g *IslandGroup) Detail(ctx context.Context, params generated.IslandDetailP query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/island/v1/detail", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/island/v1/detail", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err diff --git a/go/mark.go b/go/mark.go index e21fdac..86264be 100644 --- a/go/mark.go +++ b/go/mark.go @@ -83,12 +83,11 @@ func (g *MarkGroup) Add(ctx context.Context, body generated.MarkAddJSONBody) (*M return nil, err } - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/mark/v1/add", - jsonBody: body, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/mark/v1/add", + jsonBody: body, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -111,12 +110,11 @@ func (g *MarkGroup) List(ctx context.Context, params generated.MarkListParams) ( query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/list", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/list", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -140,12 +138,11 @@ func (g *MarkGroup) Search(ctx context.Context, params generated.MarkSearchParam query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/search", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/search", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -169,12 +166,11 @@ func (g *MarkGroup) Detail(ctx context.Context, params generated.MarkDetailParam query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/detail", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/detail", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err diff --git a/go/spu.go b/go/spu.go index f2517d1..04078bb 100644 --- a/go/spu.go +++ b/go/spu.go @@ -61,12 +61,11 @@ func (g *SpuGroup) Detail(ctx context.Context, params generated.SpuDetailParams) query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/spu/v1/detail", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/spu/v1/detail", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -108,12 +107,11 @@ func (g *SpuGroup) Search(ctx context.Context, params generated.SpuSearchParams) setIfNotNil(query, "offset", params.Offset) setIfNotNil(query, "limit", params.Limit) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/spu/v1/search", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/spu/v1/search", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err diff --git a/go/tag.go b/go/tag.go index 8fd4e8c..4cca04a 100644 --- a/go/tag.go +++ b/go/tag.go @@ -46,12 +46,11 @@ func (g *TagGroup) Detail(ctx context.Context, params generated.TagDetailParams) query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/tag/v1/detail", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/tag/v1/detail", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -78,12 +77,11 @@ func (g *TagGroup) Search(ctx context.Context, params generated.TagSearchParams) setIfNotNil(query, "offset", params.Offset) setIfNotNil(query, "limit", params.Limit) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/tag/v1/search", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/tag/v1/search", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err 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/go/user.go b/go/user.go index 629e9cf..2989571 100644 --- a/go/user.go +++ b/go/user.go @@ -31,11 +31,10 @@ func (g *UserGroup) Me(ctx context.Context) (*UserMeResult, error) { return nil, err } - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/user/v1/me", - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/user/v1/me", + authScheme: authSchemeStandard, }) if err != nil { return nil, err diff --git a/go/usercredential.go b/go/usercredential.go new file mode 100644 index 0000000..13c0d02 --- /dev/null +++ b/go/usercredential.go @@ -0,0 +1,380 @@ +package qdmp + +// Automatic renewal of a user-level credential ("用户授权凭证"). +// +// A user access token lives 7200 seconds; the refresh token that renews it +// lives 30 days. Rather than making every caller notice expiry themselves, +// WithUserCredential derives a *UserClient that renews on two triggers: +// +// - before a business call, when the known expiry is within +// tokenRefreshBufferSeconds (the same 300s buffer the app-level +// credential uses); +// - after a business call comes back HTTP 401 with business code 10005 +// ("access_token 格式错误或已吊销") or 10006 ("access_token 超过有效期"), +// in which case the original request is retried exactly once. +// +// Nothing else renews. A 5xx, a transport error, an unrelated business +// error, or even a 401 carrying some other code are all handed back to the +// caller untouched — renewing on those would burn refresh attempts on +// failures a new token cannot fix. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +// The two business codes that mean "this access token is no longer usable, +// get a new one" (see shared/generated/error-codes.json). Both are reported +// with HTTP 401. +const ( + codeAccessTokenRevoked = "10005" + codeAccessTokenExpired = "10006" +) + +// UserCredentialOptions configures Client.WithUserCredential. +type UserCredentialOptions struct { + // AccessToken is the current user access token. Required. + AccessToken string + // RefreshToken is the token used to renew AccessToken. Required: without + // it renewal is impossible, and a caller holding only an access token + // wants WithAccessToken instead. + RefreshToken string + // ExpiresAt is AccessToken's absolute expiry as Unix seconds in a string, + // exactly as the server sent it. Optional: leaving it empty means "expiry + // unknown", which skips proactive renewal entirely and leaves a 401 as + // the only renewal trigger. + ExpiresAt string + // OnRefresh, when set, is called after a successful renewal has already + // been applied to the client's own state, so the caller can persist the + // new token. It receives the context of the business call that triggered + // the renewal. Returning an error fails that business call with that + // error; the renewed token is kept regardless (it has already been issued + // — discarding it would only force another renewal). + OnRefresh func(ctx context.Context, token RefreshedUserToken) error +} + +// RefreshedUserToken is what a renewal produced. It carries no refreshToken +// because POST /auth/v1/refresh does not issue one: the original refresh +// token stays valid and unchanged. +type RefreshedUserToken struct { + AccessToken string + ExpiresAt string +} + +// UserCredential is the read-only snapshot returned by UserClient.Credential. +type UserCredential struct { + AccessToken string + RefreshToken string + // ExpiresAt is the wire-shaped absolute Unix-seconds string, or empty + // when the expiry is unknown. + ExpiresAt string +} + +// String implements fmt.Stringer so an incidental debug print of a +// credential never puts a live token in a log line. Same rationale (and same +// [REDACTED] placeholder) as UserAccessTokenResult.String. +func (o UserCredentialOptions) String() string { + return fmt.Sprintf( + "UserCredentialOptions{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OnRefresh:%s}", + "[REDACTED]", "[REDACTED]", o.ExpiresAt, callbackPresence(o.OnRefresh), + ) +} + +// GoString implements fmt.GoStringer — %#v does not consult fmt.Stringer. +func (o UserCredentialOptions) GoString() string { + return fmt.Sprintf( + "qdmp.UserCredentialOptions{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OnRefresh:%s}", + "[REDACTED]", "[REDACTED]", o.ExpiresAt, callbackPresence(o.OnRefresh), + ) +} + +// LogValue implements slog.LogValuer — log/slog consults neither Stringer +// nor GoStringer. +func (o UserCredentialOptions) LogValue() slog.Value { + return slog.GroupValue( + slog.String("AccessToken", "[REDACTED]"), + slog.String("RefreshToken", "[REDACTED]"), + slog.String("ExpiresAt", o.ExpiresAt), + slog.String("OnRefresh", callbackPresence(o.OnRefresh)), + ) +} + +// String implements fmt.Stringer — see UserCredentialOptions.String for why. +func (t RefreshedUserToken) String() string { + return fmt.Sprintf("RefreshedUserToken{AccessToken:%q, ExpiresAt:%q}", "[REDACTED]", t.ExpiresAt) +} + +// GoString implements fmt.GoStringer — see UserCredentialOptions.GoString for why. +func (t RefreshedUserToken) GoString() string { + return fmt.Sprintf("qdmp.RefreshedUserToken{AccessToken:%q, ExpiresAt:%q}", "[REDACTED]", t.ExpiresAt) +} + +// LogValue implements slog.LogValuer — see UserCredentialOptions.LogValue for why. +func (t RefreshedUserToken) LogValue() slog.Value { + return slog.GroupValue( + slog.String("AccessToken", "[REDACTED]"), + slog.String("ExpiresAt", t.ExpiresAt), + ) +} + +// String implements fmt.Stringer — see UserCredentialOptions.String for why. +func (c UserCredential) String() string { + return fmt.Sprintf( + "UserCredential{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q}", + "[REDACTED]", "[REDACTED]", c.ExpiresAt, + ) +} + +// GoString implements fmt.GoStringer — see UserCredentialOptions.GoString for why. +func (c UserCredential) GoString() string { + return fmt.Sprintf( + "qdmp.UserCredential{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q}", + "[REDACTED]", "[REDACTED]", c.ExpiresAt, + ) +} + +// LogValue implements slog.LogValuer — see UserCredentialOptions.LogValue for why. +func (c UserCredential) LogValue() slog.Value { + return slog.GroupValue( + slog.String("AccessToken", "[REDACTED]"), + slog.String("RefreshToken", "[REDACTED]"), + slog.String("ExpiresAt", c.ExpiresAt), + ) +} + +// callbackPresence describes a callback without printing its code pointer, +// which %v would otherwise render as a bare memory address. +func callbackPresence(fn func(ctx context.Context, token RefreshedUserToken) error) string { + if fn == nil { + return "" + } + return "" +} + +// WithUserCredential derives a *UserClient that renews its own user access +// token: proactively when the known expiry is within +// tokenRefreshBufferSeconds, and reactively on an HTTP 401 carrying business +// code 10005/10006 (after which the original request is retried once). +// +// The business group methods are exactly the same as on a WithAccessToken +// client — renewal happens underneath them, in the transport. Options are +// validated on the first business call rather than here, so a missing +// AccessToken/RefreshToken surfaces from the call site as +// ErrAccessTokenRequired/ErrRefreshTokenRequired instead of from a +// constructor that cannot return an error. +func (c *Client) WithUserCredential(opts UserCredentialOptions) *UserClient { + cred := &userCredential{ + client: c, + onRefresh: opts.OnRefresh, + accessToken: opts.AccessToken, + refreshToken: opts.RefreshToken, + } + cred.setExpiresAt(opts.ExpiresAt) + + uc := c.WithAccessToken(opts.AccessToken) + uc.cred = cred + return uc +} + +// userRefreshInflight is one renewal that concurrent business calls can wait +// on instead of each issuing their own (single-flight). +type userRefreshInflight struct { + done chan struct{} + token string + gen uint64 + err error +} + +// userCredential is the mutable state behind a WithUserCredential client. +// +// generation is what makes "renew once" safe under concurrency: a business +// call remembers the generation of the token it sent, and a renewal request +// carrying a stale generation is answered with the token someone else has +// already fetched instead of issuing a second one. Without it, two calls +// that both got a 401 with the same expired token would renew twice — the +// second one throwing away a perfectly good token the first had just +// obtained. +type userCredential struct { + client *Client + onRefresh func(ctx context.Context, token RefreshedUserToken) error + + mu sync.Mutex + accessToken string + refreshToken string + expiresAt string + expiresAtUnix int64 + hasExpiry bool + generation uint64 + inflight *userRefreshInflight +} + +// setExpiresAt records a wire expiresAt string. An unparseable or negative +// value is treated exactly like an absent one — expiry unknown — so a +// malformed timestamp degrades to "renew only on 401" rather than either +// renewing on every single call or trusting a nonsense expiry. +func (c *userCredential) setExpiresAt(raw string) { + c.expiresAt = raw + if raw == "" { + c.expiresAtUnix, c.hasExpiry = 0, false + return + } + parsed, err := parseExpiresAt(raw) + if err != nil { + c.expiresAtUnix, c.hasExpiry = 0, false + return + } + c.expiresAtUnix, c.hasExpiry = parsed, true +} + +// snapshot returns an independent copy of the current credential state. +func (c *userCredential) snapshot() UserCredential { + c.mu.Lock() + defer c.mu.Unlock() + return UserCredential{ + AccessToken: c.accessToken, + RefreshToken: c.refreshToken, + ExpiresAt: c.expiresAt, + } +} + +// current reports the token to send next, the generation it belongs to, and +// whether it is close enough to expiry to renew first. All three are read +// under one lock so the generation always describes the token returned +// alongside it. +func (c *userCredential) current() (token string, gen uint64, stale bool) { + c.mu.Lock() + defer c.mu.Unlock() + token, gen = c.accessToken, c.generation + stale = c.hasExpiry && c.expiresAtUnix-tokenRefreshBufferSeconds <= time.Now().Unix() + return token, gen, stale +} + +// do runs one business request with renewal wrapped around it: renew first +// if the token is about to expire, then send; and if the response says the +// token was rejected, renew and retry exactly once. +func (c *userCredential) do(ctx context.Context, p requestParams) (json.RawMessage, error) { + token, gen, stale := c.current() + if stale { + var err error + token, gen, err = c.refresh(ctx, gen) + if err != nil { + return nil, err + } + } + + p.accessToken = token + data, err := c.client.doRequest(ctx, p) + if err == nil || !isExpiredAccessTokenError(err) { + return data, err + } + + // The token was rejected. Renew once and replay the request; whatever the + // replay returns is final — no second renewal, no second retry. + retryToken, _, refreshErr := c.refresh(ctx, gen) + if refreshErr != nil { + // The renewal itself failed (e.g. code 10008, the refresh token is + // gone too). Surface that, not the original 401: the caller has to + // re-run the authorization-code flow, and retrying the business + // request would be pointless. + return nil, refreshErr + } + p.accessToken = retryToken + return c.client.doRequest(ctx, p) +} + +// isExpiredAccessTokenError reports whether an error is specifically "the +// access token was rejected" — HTTP 401 *and* business code 10005/10006. +// Both halves matter: HTTP status is not authoritative on this API, and a +// 401 carrying any other code means something a new token would not fix. +func isExpiredAccessTokenError(err error) bool { + var apiErr *QdmpApiError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.HTTPStatus != http.StatusUnauthorized { + return false + } + return apiErr.Code == codeAccessTokenRevoked || apiErr.Code == codeAccessTokenExpired +} + +// refresh renews the access token, collapsing concurrent callers into a +// single exchange, and returns the new token with its generation. +// +// seenGen is the generation of the token that prompted this call. If the +// stored generation has already moved past it, someone else's renewal has +// landed in the meantime and its token is returned as-is — this is what +// keeps a burst of concurrent 401s (or a burst of cold-start proactive +// renewals) down to one exchange. +func (c *userCredential) refresh(ctx context.Context, seenGen uint64) (string, uint64, error) { + c.mu.Lock() + if c.generation != seenGen { + token, gen := c.accessToken, c.generation + c.mu.Unlock() + return token, gen, nil + } + if existing := c.inflight; existing != nil { + c.mu.Unlock() + select { + case <-existing.done: + return existing.token, existing.gen, existing.err + case <-ctx.Done(): + return "", 0, ctx.Err() + } + } + inflight := &userRefreshInflight{done: make(chan struct{})} + c.inflight = inflight + refreshToken := c.refreshToken + c.mu.Unlock() + + token, gen, err := c.exchange(ctx, refreshToken) + + c.mu.Lock() + c.inflight = nil + c.mu.Unlock() + + inflight.token, inflight.gen, inflight.err = token, gen, err + close(inflight.done) + return token, gen, err +} + +// exchange performs the actual renewal: POST /auth/v1/refresh with the +// original refresh token and no access-token header, then applies the result +// to this credential and notifies OnRefresh. +// +// Ordering matters here. Internal state is updated *before* OnRefresh runs, +// and is deliberately not rolled back when OnRefresh fails: the server has +// already issued the new token and invalidated nothing, so discarding it +// because the caller's own persistence failed would just force another +// renewal on the next call. The OnRefresh error still fails this business +// call, so the caller does learn that persistence failed. +func (c *userCredential) exchange(ctx context.Context, refreshToken string) (string, uint64, error) { + result, err := c.client.Auth.RefreshToken(ctx, refreshToken) + if err != nil { + return "", 0, err + } + + c.mu.Lock() + c.accessToken = result.AccessToken + c.setExpiresAt(result.ExpiresAt) + // The refresh token is intentionally left alone: /auth/v1/refresh returns + // no new one, and the old one stays valid. + c.generation++ + gen := c.generation + c.mu.Unlock() + + if c.onRefresh != nil { + if err := c.onRefresh(ctx, RefreshedUserToken{ + AccessToken: result.AccessToken, + ExpiresAt: result.ExpiresAt, + }); err != nil { + return "", 0, err + } + } + return result.AccessToken, gen, nil +} diff --git a/go/usercredential_test.go b/go/usercredential_test.go new file mode 100644 index 0000000..52b8cca --- /dev/null +++ b/go/usercredential_test.go @@ -0,0 +1,935 @@ +package qdmp_test + +// Contract exercised by this file (see CONTRACT.md §2 — not yet implemented): +// +// uc := client.WithUserCredential(qdmp.UserCredentialOptions{ +// AccessToken: "at_xxx", +// RefreshToken: "rt_xxx", +// ExpiresAt: "1785766657", // optional, Unix-seconds string +// OnRefresh: func(ctx context.Context, t qdmp.RefreshedUserToken) error { return nil }, +// }) +// me, err := uc.User.Me(ctx) // business method signatures unchanged +// snapshot := uc.Credential() // read-only snapshot +// +// WithUserCredential reuses the existing *UserClient business-group type +// (uc.User, uc.GenAI, ...); it only wires automatic refresh into the +// transport used underneath. WithAccessToken keeps its current +// static-token, no-auto-refresh semantics untouched. +// +// Behavior contract under test (CONTRACT.md §2.3, plus §2.4/§2.5): +// 1. Proactive refresh: expiresAt-now <= 300s triggers a refresh before the +// business call goes out. +// 2. Refresh action: POST /auth/v1/refresh, body {"refreshToken": ""}, +// authScheme noAuth (no access-token header); refreshToken never changes. +// 3. onRefresh runs after internal state is updated; if it errors, the +// whole business call fails with that error but the new token is kept +// (no rollback). +// 4. 401 + code 10005/10006 -> refresh once, retry the original request +// exactly once. +// 5. A second failure after the retry is returned as-is, no further retry. +// 6. 5xx / network errors / non-401 business errors / 401 with an +// unrelated code must never trigger a refresh. +// 7. Refresh failure itself is surfaced as-is, no business retry. +// 8. Concurrent callers collapse into a single in-flight refresh. +// 9. Credential() is a read-only, independent snapshot. +// 10. genai (x-openapi-access-token / x-openapi-app-id headers) gets the +// same auto-refresh/401-retry behavior. + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + qdmp "github.com/EchoTechFE/qdmp-server-sdk/go" + "github.com/EchoTechFE/qdmp-server-sdk/go/generated" +) + +const ( + ucOriginalRefreshToken = "uc-original-refresh-token" +) + +// futureExpiresAt returns a wire-shaped (string, Unix-seconds) expiresAt +// value d in the future, computed relative to time.Now() so these tests +// never go stale as real wall-clock time advances (matching the existing +// convention in auth_test.go). +func futureExpiresAt(d time.Duration) string { + return strconv.FormatInt(time.Now().Add(d).Unix(), 10) +} + +// snapshotStringField reads a string-typed exported field by name off +// whatever uc.Credential() returns, transparently handling both a value type +// and a pointer type, without this test file needing to know (or guess) the +// snapshot's concrete type name. +func snapshotStringField(t *testing.T, snapshot any, name string) string { + t.Helper() + v := reflect.ValueOf(snapshot) + if v.Kind() == reflect.Ptr { + if v.IsNil() { + t.Fatalf("Credential() returned a nil *%s", v.Type().Elem().Name()) + } + v = v.Elem() + } + f := v.FieldByName(name) + if !f.IsValid() { + t.Fatalf("Credential() snapshot has no field %q (got type %s)", name, v.Type()) + } + return f.String() +} + +// tryTamperStringField attempts to mutate a string field on the snapshot +// in place via reflection. This only succeeds (and thus only matters) if +// Credential() handed back a pointer into the client's real internal state +// instead of an independent copy — which is exactly the bug this exists to +// catch. If Credential() returns a plain value type, CanSet() is false and +// this is a harmless no-op (the immutability property then holds by +// construction, as Go value semantics already guarantee it). +func tryTamperStringField(snapshot any, name, newValue string) { + v := reflect.ValueOf(snapshot) + if v.Kind() != reflect.Ptr || v.IsNil() { + return + } + v = v.Elem() + f := v.FieldByName(name) + if f.IsValid() && f.CanSet() && f.Kind() == reflect.String { + f.SetString(newValue) + } +} + +// --- 1/2: proactive refresh ------------------------------------------------- + +// TestUserCredential_ProactiveRefresh_WithinBuffer verifies bullet 1: an +// expiresAt sitting inside the 300s buffer must trigger a refresh BEFORE the +// business call is sent, and the business call must then use the freshly +// refreshed access token (not the stale constructor-supplied one). +func TestUserCredential_ProactiveRefresh_WithinBuffer(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/auth/v1/refresh" { + t.Fatalf("unexpected refresh request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("access-token"); got != "" { + t.Fatalf("refresh request must not send an access-token header (authScheme noAuth), got %q", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode refresh body: %v", err) + } + if body["refreshToken"] != ucOriginalRefreshToken { + t.Fatalf("refresh body refreshToken = %v, want %q", body["refreshToken"], ucOriginalRefreshToken) + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-1", map[string]any{ + "accessToken": "freshly-refreshed-token", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("access-token"); got != "freshly-refreshed-token" { + t.Fatalf("business call access-token header = %q, want the proactively refreshed token %q", got, "freshly-refreshed-token") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-1", map[string]any{ + "id": "user-uc-1", + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "stale-access-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(250 * time.Second), // inside the 300s buffer + }) + + me, err := uc.User.Me(context.Background()) + if err != nil { + t.Fatalf("User.Me() error = %v, want nil", err) + } + if me.ID != "user-uc-1" { + t.Fatalf("User.Me() result = %+v, want ID = user-uc-1", me) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) + } +} + +// TestUserCredential_NoProactiveRefresh_OutsideBuffer is the complementary +// boundary case: an expiresAt just outside the 300s buffer must not trigger +// any refresh at all, and the business call must go out with the original +// access token. +func TestUserCredential_NoProactiveRefresh_OutsideBuffer(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: expiresAt is outside the 300s buffer") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("access-token"); got != "still-fresh-access-token" { + t.Fatalf("business call access-token header = %q, want the original token %q (no refresh should have happened)", got, "still-fresh-access-token") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-2", map[string]any{ + "id": "user-uc-2", + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "still-fresh-access-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(301 * time.Second), // just outside the 300s buffer + }) + + me, err := uc.User.Me(context.Background()) + if err != nil { + t.Fatalf("User.Me() error = %v, want nil", err) + } + if me.ID != "user-uc-2" { + t.Fatalf("User.Me() result = %+v, want ID = user-uc-2", me) + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0 (expiresAt is outside the refresh buffer)", refreshCounter.Count()) + } +} + +// TestUserCredential_ExpiresAtEmpty_SkipsProactiveRefresh verifies that an +// unset ExpiresAt ("unknown expiry") skips proactive refresh entirely: the +// very first business call must go straight to the network with the +// constructor-supplied token, relying only on 401-triggered refresh. +func TestUserCredential_ExpiresAtEmpty_SkipsProactiveRefresh(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: expiresAt is unknown, so only 401 can trigger a refresh") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-3", map[string]any{ + "id": "user-uc-3", + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "unknown-expiry-token", + RefreshToken: ucOriginalRefreshToken, + // ExpiresAt intentionally omitted. + }) + + _, err := uc.User.Me(context.Background()) + if err != nil { + t.Fatalf("User.Me() error = %v, want nil", err) + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0 (unknown expiresAt must skip proactive refresh)", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) + } +} + +// --- 3: onRefresh timing and failure semantics ------------------------------ + +// TestUserCredential_OnRefresh_CalledWithNewTokenAndCallerContext verifies +// onRefresh actually receives the refreshed accessToken/expiresAt (not the +// stale ones) and is invoked with the same context.Context the caller passed +// into the business method (not a detached context.Background()). +func TestUserCredential_OnRefresh_CalledWithNewTokenAndCallerContext(t *testing.T) { + wantExpiresAt := futureExpiresAt(time.Hour) + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-2", map[string]any{ + "accessToken": "onrefresh-new-token", + "expiresAt": wantExpiresAt, + }) + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-4", map[string]any{"id": "user-uc-4"}) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + type ctxKeyType struct{} + var ctxKey ctxKeyType + var onRefreshCalls int64 + var sawAccessToken, sawExpiresAt string + var sawCtxValue any + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "stale-access-token-2", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(200 * time.Second), + OnRefresh: func(ctx context.Context, tok qdmp.RefreshedUserToken) error { + atomic.AddInt64(&onRefreshCalls, 1) + sawAccessToken = tok.AccessToken + sawExpiresAt = tok.ExpiresAt + sawCtxValue = ctx.Value(ctxKey) + return nil + }, + }) + + ctx := context.WithValue(context.Background(), ctxKey, "caller-marker") + if _, err := uc.User.Me(ctx); err != nil { + t.Fatalf("User.Me() error = %v, want nil", err) + } + if atomic.LoadInt64(&onRefreshCalls) != 1 { + t.Fatalf("onRefresh called %d times, want exactly 1", onRefreshCalls) + } + if sawAccessToken != "onrefresh-new-token" { + t.Fatalf("onRefresh saw AccessToken = %q, want %q", sawAccessToken, "onrefresh-new-token") + } + if sawExpiresAt != wantExpiresAt { + t.Fatalf("onRefresh saw ExpiresAt = %q, want %q", sawExpiresAt, wantExpiresAt) + } + if sawCtxValue != "caller-marker" { + t.Fatalf("onRefresh's ctx.Value(ctxKey) = %v, want %q (onRefresh must run with the caller's context, not a detached one)", sawCtxValue, "caller-marker") + } +} + +// TestUserCredential_OnRefreshError_FailsCallButKeepsNewToken is the most +// important onRefresh test: onRefresh returning an error must fail the +// triggering business call with that exact error (not swallow it, not +// substitute a different one), the business endpoint must never be reached +// in that same call (the whole call fails on account of onRefresh, not a +// business-layer failure), and — critically — the already-refreshed token +// must NOT be rolled back: a subsequent call, once expiresAt is safely in +// the future, must proceed straight to the business endpoint using the new +// token and must NOT trigger a second refresh. +func TestUserCredential_OnRefreshError_FailsCallButKeepsNewToken(t *testing.T) { + sentinelErr := errors.New("uc: failed to persist refreshed token") + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-3", map[string]any{ + "accessToken": "refreshed-despite-onrefresh-error", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("access-token"); got != "refreshed-despite-onrefresh-error" { + t.Fatalf("business call access-token header = %q, want the retained refreshed token %q", got, "refreshed-despite-onrefresh-error") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-5", map[string]any{"id": "user-uc-5"}) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "stale-access-token-3", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(100 * time.Second), // forces a refresh on the first call + OnRefresh: func(ctx context.Context, tok qdmp.RefreshedUserToken) error { + return sentinelErr + }, + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want onRefresh's error to fail the whole call") + } + if !errors.Is(err, sentinelErr) { + t.Fatalf("User.Me() error = %v, want errors.Is(err, sentinelErr) (onRefresh's error must propagate, not be swallowed or replaced)", err) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } + if businessCounter.Count() != 0 { + t.Fatalf("business endpoint hit %d times, want 0 (the call must fail on onRefresh's error before ever reaching the business endpoint)", businessCounter.Count()) + } + + // Second call: expiresAt is now far in the future (from the successful + // refresh above), so this must go straight to the business endpoint + // using the retained new token, without refreshing again. + me, err := uc.User.Me(context.Background()) + if err != nil { + t.Fatalf("second User.Me() error = %v, want nil", err) + } + if me.ID != "user-uc-5" { + t.Fatalf("second User.Me() result = %+v, want ID = user-uc-5", me) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times after the second call, want still exactly 1 (no rollback => no second refresh needed)", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) + } +} + +// --- 4/5: 401-triggered refresh + retry-exactly-once ------------------------ + +// TestUserCredential_401Code10006_RefreshesAndRetriesExactlyOnce verifies +// bullets 4 and 5 together, using an exact atomic hit-count assertion (not +// "at least once"): the first business call returns 401/10006, a refresh +// happens, and the retried call (using the new token) succeeds. +func TestUserCredential_401Code10006_RefreshesAndRetriesExactlyOnce(t *testing.T) { + var businessHits int64 + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-4", map[string]any{ + "accessToken": "retried-access-token", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt64(&businessHits, 1) + if n == 1 { + if got := r.Header.Get("access-token"); got != "expired-access-token" { + t.Fatalf("first business call access-token header = %q, want %q", got, "expired-access-token") + } + businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401", nil) + return + } + if got := r.Header.Get("access-token"); got != "retried-access-token" { + t.Fatalf("retried business call access-token header = %q, want %q", got, "retried-access-token") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-6", map[string]any{"id": "user-uc-6"}) + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "expired-access-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), // outside the buffer: no proactive refresh + }) + + me, err := uc.User.Me(context.Background()) + if err != nil { + t.Fatalf("User.Me() error = %v, want nil (must succeed after the automatic 401 retry)", err) + } + if me.ID != "user-uc-6" { + t.Fatalf("User.Me() result = %+v, want ID = user-uc-6", me) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } + if got := atomic.LoadInt64(&businessHits); got != 2 { + t.Fatalf("business endpoint hit %d times, want exactly 2 (original + exactly one retry)", got) + } +} + +// TestUserCredential_401StillFailsAfterRetry_NoSecondRefresh verifies bullet +// 5's "give up after one retry" half: if the retried call still fails, the +// error is returned as-is and there must be no second refresh/retry attempt +// (which would show up as unbounded growth in either hit counter). +func TestUserCredential_401StillFailsAfterRetry_NoSecondRefresh(t *testing.T) { + var businessHits int64 + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-5", map[string]any{ + "accessToken": "still-bad-access-token", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&businessHits, 1) + businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401-again", nil) + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "expired-access-token-2", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + me, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want the retried call's 401/10006 to surface once the retry also fails") + } + if me != nil { + t.Fatalf("User.Me() result = %+v, want nil on failure", me) + } + apiErr, ok := err.(*qdmp.QdmpApiError) + if !ok { + t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) + } + if apiErr.Code != "10006" { + t.Fatalf("apiErr.Code = %q, want %q", apiErr.Code, "10006") + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1 (no second refresh attempt after the retry also failed)", refreshCounter.Count()) + } + if got := atomic.LoadInt64(&businessHits); got != 2 { + t.Fatalf("business endpoint hit %d times, want exactly 2 (original + exactly one retry, then give up)", got) + } +} + +// --- 6/7: non-retryable failures must never refresh ------------------------- + +// TestUserCredential_401WrongCode_NoRefresh verifies the narrow, easy-to-get- +// wrong boundary in bullet 6: HTTP 401 alone is not sufficient to trigger a +// refresh — the business code must specifically be 10005 or 10006. A 401 +// with an unrelated code (10001) must be returned as-is, untouched. +func TestUserCredential_401WrongCode_NoRefresh(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: 401 with code=10001 is not a retryable auth failure") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusUnauthorized, float64(10001), "code 已使用或已过期", "req-uc-me-7", nil) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "some-access-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want the 401/10001 error surfaced as-is") + } + apiErr, ok := err.(*qdmp.QdmpApiError) + if !ok { + t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) + } + if apiErr.Code != "10001" { + t.Fatalf("apiErr.Code = %q, want %q", apiErr.Code, "10001") + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a non-retryable code)", businessCounter.Count()) + } +} + +// TestUserCredential_NonAuthBusinessError_NoRefresh verifies a plain business +// failure (HTTP 200, unrelated non-zero code) never triggers a refresh. +func TestUserCredential_NonAuthBusinessError_NoRefresh(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: this is an unrelated business error, not an auth failure") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, float64(20000), "some unrelated business failure", "req-uc-me-8", nil) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "some-access-token-2", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want the code=20000 business error surfaced") + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) + } +} + +// TestUserCredential_HTTP5xx_NoRefresh verifies a transport-level 5xx (no +// business envelope at all) never triggers a refresh. +func TestUserCredential_HTTP5xx_NoRefresh(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: a 5xx is not an auth failure") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "some-access-token-3", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want a non-nil error for HTTP 500") + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0 (a 5xx must never trigger a refresh)", refreshCounter.Count()) + } + if businessCounter.Count() != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a 5xx)", businessCounter.Count()) + } +} + +// TestUserCredential_NetworkError_NoRefresh verifies a genuine +// transport/network failure (connection closed mid-request, no HTTP response +// at all — not even a status code) never triggers a refresh either. This is +// distinct from the 5xx case above: there is no parsed response to inspect +// at all here, so a naive "refresh on any error" implementation would be +// caught by this test where the 5xx test alone would not. +func TestUserCredential_NetworkError_NoRefresh(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: a network/transport error is not an auth failure") + }) + var businessHits int64 + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&businessHits, 1) + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatalf("response writer does not support hijacking; cannot simulate a network error") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatalf("failed to hijack connection: %v", err) + } + conn.Close() // close without ever writing a response: a genuine transport error + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "some-access-token-4", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want a non-nil error for a closed connection with no response") + } + if refreshCounter.Count() != 0 { + t.Fatalf("refresh endpoint hit %d times, want 0 (a network error must never trigger a refresh)", refreshCounter.Count()) + } + if got := atomic.LoadInt64(&businessHits); got != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a network error)", got) + } +} + +// TestUserCredential_RefreshItselfFails_BusinessNeverRetried verifies bullet +// 7: when /auth/v1/refresh itself fails (e.g. code 10008, expired +// refreshToken), that error must be surfaced to the caller as-is, and the +// original business request must never be retried. +func TestUserCredential_RefreshItselfFails_BusinessNeverRetried(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, float64(10008), "refreshToken 超过有效期", "req-uc-refresh-fail", nil) + }) + var businessHits int64 + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&businessHits, 1) + businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401-refreshfail", nil) + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "expired-access-token-3", + RefreshToken: "an-expired-refresh-token", + ExpiresAt: futureExpiresAt(time.Hour), + }) + + _, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want the refresh failure (code=10008) to surface") + } + apiErr, ok := err.(*qdmp.QdmpApiError) + if !ok { + t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) + } + if apiErr.Code != "10008" { + t.Fatalf("apiErr.Code = %q, want %q (the refresh failure's own code, not the original 401/10006)", apiErr.Code, "10008") + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } + if got := atomic.LoadInt64(&businessHits); got != 1 { + t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry: the refresh itself failed, so the business request must not be retried)", got) + } +} + +// --- 8: concurrent single-flight --------------------------------------------- + +// TestUserCredential_ConcurrentCalls_RefreshSingleFlight drives N goroutines +// to call User.Me concurrently on the same *user* credential, all starting +// from a cold, within-buffer expiresAt so every one of them independently +// "wants" a refresh. Without a single-flight guard, this would issue N +// refresh calls; the mock refresh handler sleeps briefly to widen the race +// window a naive implementation would otherwise get lucky and miss. Must +// pass under go test -race. +func TestUserCredential_ConcurrentCalls_RefreshSingleFlight(t *testing.T) { + const goroutines = 20 + var refreshHits int64 + var businessHits int64 + + mux := http.NewServeMux() + mux.HandleFunc("/auth/v1/refresh", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&refreshHits, 1) + time.Sleep(50 * time.Millisecond) // widen the race window + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-concurrent", map[string]any{ + "accessToken": "single-flighted-token", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&businessHits, 1) + if got := r.Header.Get("access-token"); got != "single-flighted-token" { + t.Errorf("business call access-token header = %q, want %q", got, "single-flighted-token") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-concurrent", map[string]any{"id": "user-uc-concurrent"}) + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "cold-access-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(50 * time.Second), // well within the 300s buffer + }) + + var wg sync.WaitGroup + errs := make([]error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(i int) { + defer wg.Done() + _, errs[i] = uc.User.Me(context.Background()) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: User.Me() error = %v, want nil", i, err) + } + } + if got := atomic.LoadInt64(&refreshHits); got != 1 { + t.Fatalf("refresh endpoint hit %d times by %d concurrent callers, want exactly 1 (single-flight failed)", got, goroutines) + } + if got := atomic.LoadInt64(&businessHits); got != goroutines { + t.Fatalf("business endpoint hit %d times, want exactly %d (one per caller, after the shared refresh completed)", got, goroutines) + } +} + +// --- 9: read-only snapshot --------------------------------------------------- + +// TestUserCredential_Credential_ReflectsStateAndIsIndependentCopy verifies +// bullet 9 end-to-end: Credential() must report the live internal state +// (not a stale constructor-time copy) after a refresh, and mutating the +// returned value must never leak back into the client's internal state. +func TestUserCredential_Credential_ReflectsStateAndIsIndependentCopy(t *testing.T) { + wantExpiresAt := futureExpiresAt(time.Hour) + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-6", map[string]any{ + "accessToken": "credential-snapshot-token", + "expiresAt": wantExpiresAt, + }) + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("access-token"); got != "credential-snapshot-token" { + t.Fatalf("business call access-token header = %q, want %q (tampering with the snapshot must not affect internal state)", got, "credential-snapshot-token") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-9", map[string]any{"id": "user-uc-9"}) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "pre-refresh-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(100 * time.Second), // forces a refresh on the first call + }) + + if _, err := uc.User.Me(context.Background()); err != nil { + t.Fatalf("User.Me() error = %v, want nil", err) + } + + snap := uc.Credential() + if got := snapshotStringField(t, snap, "AccessToken"); got != "credential-snapshot-token" { + t.Fatalf("Credential().AccessToken = %q, want the refreshed token %q (Credential() must reflect live state)", got, "credential-snapshot-token") + } + if got := snapshotStringField(t, snap, "ExpiresAt"); got != wantExpiresAt { + t.Fatalf("Credential().ExpiresAt = %q, want %q", got, wantExpiresAt) + } + + // Attempt to tamper with the snapshot in place (only has an effect if + // Credential() handed back a pointer into the real internal state, + // which would be the bug). + tryTamperStringField(snap, "AccessToken", "tampered-value-should-not-stick") + + snap2 := uc.Credential() + if got := snapshotStringField(t, snap2, "AccessToken"); got != "credential-snapshot-token" { + t.Fatalf("Credential().AccessToken after tampering with a prior snapshot = %q, want it unaffected (%q) — Credential() must return an independent copy", got, "credential-snapshot-token") + } + + // And a subsequent business call must still use the real, untampered + // token (expiresAt is far in the future now, so no new refresh fires). + if _, err := uc.User.Me(context.Background()); err != nil { + t.Fatalf("second User.Me() error = %v, want nil", err) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } +} + +// --- local validation (§2.2): must fail before any HTTP request ------------- + +// TestUserCredential_EmptyAccessToken_LocalErrorNoRequest verifies an empty +// AccessToken is rejected locally, reusing the same sentinel already used by +// WithAccessToken("") elsewhere in this suite (see TestUserMe_MissingAccessToken_NoRequestSent), +// and that this happens before any HTTP request (refresh or business) is +// sent. +func TestUserCredential_EmptyAccessToken_LocalErrorNoRequest(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: local validation must fail first") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("business endpoint should never have been called: local validation must fail first") + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + me, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want a local validation error for an empty AccessToken") + } + if me != nil { + t.Fatalf("User.Me() result = %+v, want nil on validation failure", me) + } + if !errors.Is(err, qdmp.ErrAccessTokenRequired) { + t.Fatalf("err = %v, want errors.Is(err, qdmp.ErrAccessTokenRequired)", err) + } + if refreshCounter.Count() != 0 || businessCounter.Count() != 0 { + t.Fatalf("refresh hits = %d, business hits = %d, want 0/0 (must fail locally before any HTTP request)", refreshCounter.Count(), businessCounter.Count()) + } +} + +// TestUserCredential_MissingRefreshToken_LocalErrorNoRequest verifies an +// empty RefreshToken is also rejected locally (it is required per +// CONTRACT.md §2.2: without it, no auto-refresh is possible at all), and +// that this too happens before any HTTP request. +func TestUserCredential_MissingRefreshToken_LocalErrorNoRequest(t *testing.T) { + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("refresh endpoint should never have been called: local validation must fail first") + }) + businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("business endpoint should never have been called: local validation must fail first") + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.Handle("/user/v1/me", businessCounter) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "some-access-token-5", + RefreshToken: "", + ExpiresAt: futureExpiresAt(time.Hour), + }) + + me, err := uc.User.Me(context.Background()) + if err == nil { + t.Fatalf("User.Me() error = nil, want a local validation error for a missing RefreshToken") + } + if me != nil { + t.Fatalf("User.Me() result = %+v, want nil on validation failure", me) + } + if refreshCounter.Count() != 0 || businessCounter.Count() != 0 { + t.Fatalf("refresh hits = %d, business hits = %d, want 0/0 (must fail locally before any HTTP request)", refreshCounter.Count(), businessCounter.Count()) + } +} + +// --- 10: genai gets the same treatment --------------------------------------- + +// TestUserCredential_GenAI_401Retry verifies bullet 10: the genai group +// (which uses the x-openapi-access-token / x-openapi-app-id header pair +// instead of the standard scheme's access-token header) gets the exact same +// automatic-refresh-then-retry-exactly-once treatment as User.Me. +func TestUserCredential_GenAI_401Retry(t *testing.T) { + var businessHits int64 + refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-genai", map[string]any{ + "accessToken": "genai-retried-token", + "expiresAt": futureExpiresAt(time.Hour), + }) + }) + mux := http.NewServeMux() + mux.Handle("/auth/v1/refresh", refreshCounter) + mux.HandleFunc("/genai/v1/generate", func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt64(&businessHits, 1) + if n == 1 { + if got := r.Header.Get("x-openapi-access-token"); got != "genai-expired-token" { + t.Fatalf("first genai call x-openapi-access-token header = %q, want %q", got, "genai-expired-token") + } + businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-genai-401", nil) + return + } + if got := r.Header.Get("x-openapi-access-token"); got != "genai-retried-token" { + t.Fatalf("retried genai call x-openapi-access-token header = %q, want %q", got, "genai-retried-token") + } + if got := r.Header.Get("x-openapi-app-id"); got != "test-app-id" { + t.Fatalf("retried genai call x-openapi-app-id header = %q, want %q", got, "test-app-id") + } + businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-genai-ok", map[string]any{"id": "genai-task-uc-1"}) + }) + srv := startServer(t, mux) + client := newTestClient(t, srv.URL) + + uc := client.WithUserCredential(qdmp.UserCredentialOptions{ + AccessToken: "genai-expired-token", + RefreshToken: ucOriginalRefreshToken, + ExpiresAt: futureExpiresAt(time.Hour), + }) + + res, err := uc.GenAI.Generate(context.Background(), generated.GenaiGenerateJSONBody{ + Model: "google/gemini-3.1-flash-image-preview", + }) + if err != nil { + t.Fatalf("GenAI.Generate() error = %v, want nil (must succeed after the automatic 401 retry)", err) + } + if res.ID != "genai-task-uc-1" { + t.Fatalf("GenAI.Generate() result = %+v, want ID = genai-task-uc-1", res) + } + if refreshCounter.Count() != 1 { + t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) + } + if got := atomic.LoadInt64(&businessHits); got != 2 { + t.Fatalf("genai endpoint hit %d times, want exactly 2 (original + exactly one retry)", got) + } +} diff --git a/go/wishspu.go b/go/wishspu.go index 2fdbde3..b94e477 100644 --- a/go/wishspu.go +++ b/go/wishspu.go @@ -46,12 +46,11 @@ func (g *WishSpuGroup) Add(ctx context.Context, body generated.WishAddJSONBody) return nil, err } - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/wishspu/v1/add", - jsonBody: body, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/wishspu/v1/add", + jsonBody: body, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -70,12 +69,11 @@ func (g *WishSpuGroup) Cancel(ctx context.Context, body generated.WishCancelJSON return nil, err } - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/wishspu/v1/cancel", - jsonBody: body, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/wishspu/v1/cancel", + jsonBody: body, + authScheme: authSchemeStandard, }) if err != nil { return nil, err @@ -99,12 +97,11 @@ func (g *WishSpuGroup) List(ctx context.Context, params generated.WishListParams query.Set("offset", params.Offset) query.Set("limit", params.Limit) - data, err := g.uc.client.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/wishspu/v1/list", - query: query, - authScheme: authSchemeStandard, - accessToken: g.uc.accessToken, + data, err := g.uc.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/wishspu/v1/list", + query: query, + authScheme: authSchemeStandard, }) if err != nil { return nil, err 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..9a7afe7 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 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 + * 一次性授权码,由前端/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/AccessTokenSource.java b/java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java new file mode 100644 index 0000000..2ca862a --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java @@ -0,0 +1,44 @@ +package io.github.echotechfe.qdmp; + +/** + * Supplies the access token a single {@link QdmpTransport} call attaches, and decides whether an + * HTTP 401 that call ran into is worth exactly one retry with a renewed token. + * + *

Exists so that the "renew on 401, retry once" policy lives in one place -- {@link + * QdmpTransport} -- instead of being repeated in every method of every business group. A {@link + * QdmpContext} built from a plain token via {@link QdmpContext#of(String)} is backed by a source + * that never renews anything; a context handed out by {@link + * QdmpClient#withUserCredential(io.github.echotechfe.qdmp.auth.UserCredentialOptions)} is backed by + * one that renews the user-authorization credential through {@code POST /auth/v1/refresh}. + * + *

Implementations must be safe for concurrent use: several business calls can share one source + * and hit its methods from different threads at the same time, and a renewal triggered by one of + * them must not be duplicated by the others. + */ +public interface AccessTokenSource { + + /** + * Returns the token to attach to a request that is about to be sent, renewing it first if it is + * known to be at (or near) its expiry. + * + * @return the access token to send; never {@code null} + */ + String tokenForRequest(); + + /** + * Returns the currently held token without triggering any renewal. + * + * @return the access token held right now + */ + String currentAccessToken(); + + /** + * Reacts to a request that came back as an HTTP 401 carrying an access-token error code. + * + * @param attemptedAccessToken the token the failed request actually carried, so an implementation + * can tell "my token is stale" apart from "a concurrent caller already renewed it" + * @return the token the request should be retried once with, or {@code null} to propagate the + * original error without retrying + */ + String renewAfterUnauthorized(String attemptedAccessToken); +} 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..dac6823 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java @@ -1,6 +1,8 @@ package io.github.echotechfe.qdmp; import io.github.echotechfe.qdmp.auth.QdmpAuth; +import io.github.echotechfe.qdmp.auth.UserCredentialOptions; +import io.github.echotechfe.qdmp.auth.UserCredentialSession; import io.github.echotechfe.qdmp.groups.GenaiGroup; import io.github.echotechfe.qdmp.groups.IslandGroup; import io.github.echotechfe.qdmp.groups.MarkGroup; @@ -8,16 +10,19 @@ import io.github.echotechfe.qdmp.groups.TagGroup; import io.github.echotechfe.qdmp.groups.UserGroup; import io.github.echotechfe.qdmp.groups.WishSpuGroup; +import java.time.Clock; 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, + * {@link #withUserCredential} for a client that keeps one user-authorization credential alive on + * its own, 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 { private final QdmpAuth auth; + private final Clock clock; private final UserGroup user; private final IslandGroup island; private final SpuGroup spu; @@ -45,6 +50,7 @@ public QdmpClient(QdmpClientConfig config) { config.getAppSecret(), config.getTokenStore(), config.getClock()); + this.clock = config.getClock(); this.user = new UserGroup(transport); this.island = new IslandGroup(transport); this.spu = new SpuGroup(transport); @@ -58,6 +64,23 @@ public QdmpAuth auth() { return auth; } + /** + * Binds an already-obtained user-authorization credential to a client that keeps it alive: its + * business methods take no {@link QdmpContext}, and the SDK renews the credential ahead of a + * known expiry and once more after an HTTP 401 that reports the token invalid/expired, retrying + * the failed call. + * + *

The returned client shares this client's transport and groups; this client's own {@code + * user().me(ctx)}-style API is unaffected. + * + * @param options the credential (access token plus the refresh token to renew it with), its + * optional expiry, and an optional callback to persist each renewal + * @return a client bound to that credential + */ + public QdmpUserClient withUserCredential(UserCredentialOptions options) { + return new QdmpUserClient(this, new UserCredentialSession(auth, clock, options)); + } + public UserGroup user() { return user; } 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..36a1b69 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java @@ -9,10 +9,10 @@ */ public final class QdmpContext { - private final String accessToken; + private final AccessTokenSource tokenSource; - private QdmpContext(String accessToken) { - this.accessToken = accessToken; + private QdmpContext(AccessTokenSource tokenSource) { + this.tokenSource = tokenSource; } /** @@ -33,7 +33,22 @@ public static QdmpContext of(String accessToken) { throw new IllegalArgumentException("accessToken must not be blank"); } requireHeaderSafe(accessToken); - return new QdmpContext(accessToken); + return new QdmpContext(new StaticAccessTokenSource(accessToken)); + } + + // The context handed to the business groups by QdmpUserClient: instead of a fixed token it + // carries + // the live user-authorization credential, so QdmpTransport can renew it (proactively, or after an + // HTTP 401) without any group method knowing about renewal at all. Package-private because + // QdmpUserClient is the only legitimate caller -- external callers go through + // QdmpClient#withUserCredential. + static QdmpContext boundTo(AccessTokenSource tokenSource) { + Objects.requireNonNull(tokenSource, "tokenSource must not be null"); + return new QdmpContext(tokenSource); + } + + AccessTokenSource tokenSource() { + return tokenSource; } // Whitelists printable ASCII (0x20-0x7E) before the token ever reaches QdmpTransport's OkHttp @@ -59,7 +74,12 @@ static void requireHeaderSafe(String accessToken) { } } + /** + * Returns the access token this context currently carries, without triggering a renewal. + * + * @return the access token + */ public String getAccessToken() { - return accessToken; + return tokenSource.currentAccessToken(); } } 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..15139aa 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java @@ -5,11 +5,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.echotechfe.qdmp.errors.QdmpApiError; import io.github.echotechfe.qdmp.errors.QdmpTransportException; +import io.github.echotechfe.qdmp.generated.KnownErrorCodes; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -32,6 +34,11 @@ * 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 add a fourth responsibility: they let the context's + * {@link AccessTokenSource} renew the credential before the call and once after an HTTP 401 that + * carries {@code 10005}/{@code 10006}, retrying the request exactly once with the renewed token. + * That policy lives here, in one place, rather than in each business group's methods. */ public final class QdmpTransport { @@ -41,6 +48,15 @@ public final class QdmpTransport { // body, which readBody would buffer entirely into memory before even attempting to parse it. private static final long MAX_RESPONSE_BODY_BYTES = 10L * 1024 * 1024; + // The only two business codes that mean "this access token is no longer usable, get a new one": + // 10005 (malformed/revoked) and 10006 (past its expiry). Both are reported as a real HTTP 401, + // and both are required -- a 401 carrying any other code, or either code at any other HTTP + // status, describes a different problem and must reach the caller untouched. + private static final String ACCESS_TOKEN_INVALID_CODE = + String.valueOf(KnownErrorCodes.ACCESS_TOKEN_INVALID); + private static final String ACCESS_TOKEN_EXPIRED_CODE = + String.valueOf(KnownErrorCodes.ACCESS_TOKEN_EXPIRED); + // Generated response models have no @JsonIgnoreProperties and openapi.yaml itself flags several // `data` schemas as inferred/unverified -- the server is free to add fields we didn't predict. // Forward compatibility requires tolerating unknown JSON properties instead of throwing. @@ -117,6 +133,35 @@ public T get( return execute(requestBuilder.build(), dataType); } + /** + * Issues a GET request on behalf of a {@link QdmpContext}, letting the context's credential renew + * itself before the call and once after an access-token 401 (see {@link + * #executeWithTokenSource}). + * + * @param path the operation path, e.g. {@code /user/v1/me} + * @param scheme which header pair to attach + * @param ctx the calling context, carrying the credential to authenticate with + * @param query optional query parameters; values may be scalars or {@link List}s (repeated + * params) + * @param dataType the response {@code data} POJO type + * @param the response {@code data} type + * @return the deserialized {@code data} payload + */ + public T get( + String path, + AuthScheme scheme, + QdmpContext ctx, + Map query, + Class dataType) { + HttpUrl.Builder urlBuilder = pathUrlBuilder(path); + if (query != null) { + addQueryParams(urlBuilder, query); + } + HttpUrl url = urlBuilder.build(); + return executeWithTokenSource( + ctx.tokenSource(), scheme, () -> new Request.Builder().url(url).get(), dataType); + } + /** * Issues a POST request with a JSON body and parses the {@code data} field of a successful * business envelope into {@code dataType}. @@ -131,16 +176,81 @@ public T get( */ public T post( String path, AuthScheme scheme, String accessToken, Object body, Class dataType) { - RequestBody requestBody; + Request.Builder requestBuilder = + new Request.Builder().url(pathUrlBuilder(path).build()).post(jsonBody(body)); + applyAuthHeaders(requestBuilder, scheme, accessToken); + return execute(requestBuilder.build(), dataType); + } + + /** + * Issues a POST request on behalf of a {@link QdmpContext}, letting the context's credential + * renew itself before the call and once after an access-token 401 (see {@link + * #executeWithTokenSource}). + * + * @param path the operation path, e.g. {@code /mark/v1/add} + * @param scheme which header pair to attach + * @param ctx the calling context, carrying the credential to authenticate with + * @param body the request body, serialized as JSON + * @param dataType the response {@code data} POJO type + * @param the response {@code data} type + * @return the deserialized {@code data} payload + */ + public T post( + String path, AuthScheme scheme, QdmpContext ctx, Object body, Class dataType) { + HttpUrl url = pathUrlBuilder(path).build(); + RequestBody requestBody = jsonBody(body); + return executeWithTokenSource( + ctx.tokenSource(), + scheme, + () -> new Request.Builder().url(url).post(requestBody), + dataType); + } + + // The single place the SDK's "renew the credential, then retry the business call exactly once" + // policy is implemented -- deliberately here rather than in each of the seven business groups, + // which would otherwise have to repeat it once per operation. The retry is attempted only for an + // HTTP 401 carrying 10005/10006 (see isRenewableUnauthorized) and only if the token source + // actually produced a different token to try; everything else -- 5xx, transport failures, + // business errors at HTTP 200, a 401 with an unrelated code -- propagates untouched. The retried + // call is executed directly, never routed back through this method, so a second failure can never + // trigger a second renewal. + private T executeWithTokenSource( + AccessTokenSource tokenSource, + AuthScheme scheme, + Supplier requestFactory, + Class dataType) { + String accessToken = tokenSource.tokenForRequest(); try { - requestBody = RequestBody.create(JSON.writeValueAsBytes(body), JSON_MEDIA_TYPE); + return execute(authorize(requestFactory.get(), scheme, accessToken), dataType); + } catch (QdmpApiError e) { + if (!isRenewableUnauthorized(e)) { + throw e; + } + String renewedAccessToken = tokenSource.renewAfterUnauthorized(accessToken); + if (renewedAccessToken == null) { + throw e; + } + return execute(authorize(requestFactory.get(), scheme, renewedAccessToken), dataType); + } + } + + private static boolean isRenewableUnauthorized(QdmpApiError error) { + return error.getHttpStatus() == 401 + && (ACCESS_TOKEN_INVALID_CODE.equals(error.getCode()) + || ACCESS_TOKEN_EXPIRED_CODE.equals(error.getCode())); + } + + private Request authorize(Request.Builder requestBuilder, AuthScheme scheme, String accessToken) { + applyAuthHeaders(requestBuilder, scheme, accessToken); + return requestBuilder.build(); + } + + private static RequestBody jsonBody(Object body) { + try { + return RequestBody.create(JSON.writeValueAsBytes(body), JSON_MEDIA_TYPE); } catch (IOException e) { throw new QdmpTransportException("failed to serialize request body", e); } - Request.Builder requestBuilder = - new Request.Builder().url(pathUrlBuilder(path).build()).post(requestBody); - applyAuthHeaders(requestBuilder, scheme, accessToken); - return execute(requestBuilder.build(), dataType); } private HttpUrl.Builder pathUrlBuilder(String path) { diff --git a/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java b/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java new file mode 100644 index 0000000..54241aa --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java @@ -0,0 +1,381 @@ +package io.github.echotechfe.qdmp; + +import io.github.echotechfe.qdmp.auth.UserCredentialOptions; +import io.github.echotechfe.qdmp.auth.UserCredentialSession; +import io.github.echotechfe.qdmp.auth.UserCredentialSnapshot; +import io.github.echotechfe.qdmp.generated.GenaiDetail200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.GenaiGenerate200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.GenaiGenerateRequest; +import io.github.echotechfe.qdmp.generated.IslandDetail200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.MarkAdd200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.MarkAddRequest; +import io.github.echotechfe.qdmp.generated.MarkDetail200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.MarkList200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.MarkSearch200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.SpuDetail200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.SpuSearch200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.TagDetail200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.TagSearch200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.UserMe200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.WishAdd200ResponseAllOfData; +import io.github.echotechfe.qdmp.generated.WishAddRequest; +import io.github.echotechfe.qdmp.generated.WishCancelRequest; +import io.github.echotechfe.qdmp.generated.WishList200ResponseAllOfData; +import io.github.echotechfe.qdmp.groups.GenaiGroup; +import io.github.echotechfe.qdmp.groups.IslandGroup; +import io.github.echotechfe.qdmp.groups.MarkGroup; +import io.github.echotechfe.qdmp.groups.MarkSearchParams; +import io.github.echotechfe.qdmp.groups.SpuGroup; +import io.github.echotechfe.qdmp.groups.SpuSearchParams; +import io.github.echotechfe.qdmp.groups.TagGroup; +import io.github.echotechfe.qdmp.groups.TagSearchParams; +import io.github.echotechfe.qdmp.groups.UserGroup; +import io.github.echotechfe.qdmp.groups.WishListParams; +import io.github.echotechfe.qdmp.groups.WishSpuGroup; + +/** + * A client bound to one user-authorization credential, obtained from {@link + * QdmpClient#withUserCredential(UserCredentialOptions)}. + * + *

Its business methods take no {@link QdmpContext}: the credential is held by the client, which + * renews it 300 seconds ahead of a known expiry and again -- exactly once, then retrying the call + * -- whenever a call comes back as an HTTP 401 with business code {@code 10005}/{@code 10006}. The + * root {@link QdmpClient}'s own groups are unaffected and still take a per-call context. + * + *

Every accessor returns a thin binding over the corresponding group on the root client, so + * there is exactly one implementation of each operation and exactly one implementation of the + * renewal policy (in {@link QdmpTransport}) behind both entry points. + */ +public final class QdmpUserClient { + + private final UserCredentialSession session; + private final BoundUserGroup user; + private final BoundIslandGroup island; + private final BoundSpuGroup spu; + private final BoundTagGroup tag; + private final BoundMarkGroup mark; + private final BoundWishSpuGroup wishspu; + private final BoundGenaiGroup genai; + + QdmpUserClient(QdmpClient client, UserCredentialSession session) { + this.session = session; + QdmpContext ctx = QdmpContext.boundTo(session); + this.user = new BoundUserGroup(client.user(), ctx); + this.island = new BoundIslandGroup(client.island(), ctx); + this.spu = new BoundSpuGroup(client.spu(), ctx); + this.tag = new BoundTagGroup(client.tag(), ctx); + this.mark = new BoundMarkGroup(client.mark(), ctx); + this.wishspu = new BoundWishSpuGroup(client.wishspu(), ctx); + this.genai = new BoundGenaiGroup(client.genai(), ctx); + } + + /** + * Returns the credential as it stands right now, for a caller that wants to persist it (e.g. + * before the process exits) after the SDK has renewed it. + * + * @return an immutable snapshot; a later renewal replaces it rather than mutating it, so a + * snapshot taken earlier keeps reporting the token that was current when it was taken + */ + public UserCredentialSnapshot credential() { + return session.snapshot(); + } + + /** + * Returns the {@code user} group, bound to this client's credential. + * + * @return the bound {@code user} group + */ + public BoundUserGroup user() { + return user; + } + + /** + * Returns the {@code island} group, bound to this client's credential. + * + * @return the bound {@code island} group + */ + public BoundIslandGroup island() { + return island; + } + + /** + * Returns the {@code spu} group, bound to this client's credential. + * + * @return the bound {@code spu} group + */ + public BoundSpuGroup spu() { + return spu; + } + + /** + * Returns the {@code tag} group, bound to this client's credential. + * + * @return the bound {@code tag} group + */ + public BoundTagGroup tag() { + return tag; + } + + /** + * Returns the {@code mark} group, bound to this client's credential. + * + * @return the bound {@code mark} group + */ + public BoundMarkGroup mark() { + return mark; + } + + /** + * Returns the {@code wishspu} group, bound to this client's credential. + * + * @return the bound {@code wishspu} group + */ + public BoundWishSpuGroup wishspu() { + return wishspu; + } + + /** + * Returns the {@code genai} group, bound to this client's credential. + * + * @return the bound {@code genai} group + */ + public BoundGenaiGroup genai() { + return genai; + } + + /** The {@code user} group with this client's credential supplied for you. */ + public static final class BoundUserGroup { + + private final UserGroup delegate; + private final QdmpContext ctx; + + private BoundUserGroup(UserGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Fetches the authorized user's profile. + * + * @return the user's profile + */ + public UserMe200ResponseAllOfData me() { + return delegate.me(ctx); + } + } + + /** The {@code island} group with this client's credential supplied for you. */ + public static final class BoundIslandGroup { + + private final IslandGroup delegate; + private final QdmpContext ctx; + + private BoundIslandGroup(IslandGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Fetches an island's details. + * + * @param id the island ID + * @return the island details + */ + public IslandDetail200ResponseAllOfData detail(String id) { + return delegate.detail(ctx, id); + } + } + + /** The {@code spu} group with this client's credential supplied for you. */ + public static final class BoundSpuGroup { + + private final SpuGroup delegate; + private final QdmpContext ctx; + + private BoundSpuGroup(SpuGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Fetches a SPU's details. + * + * @param id the SPU ID + * @return the SPU details + */ + public SpuDetail200ResponseAllOfData detail(String id) { + return delegate.detail(ctx, id); + } + + /** + * Searches SPUs. + * + * @param params the search filters + * @return the SPU list and total count + */ + public SpuSearch200ResponseAllOfData search(SpuSearchParams params) { + return delegate.search(ctx, params); + } + } + + /** The {@code tag} group with this client's credential supplied for you. */ + public static final class BoundTagGroup { + + private final TagGroup delegate; + private final QdmpContext ctx; + + private BoundTagGroup(TagGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Fetches a tag's details. + * + * @param id the tag ID + * @return the tag details + */ + public TagDetail200ResponseAllOfData detail(String id) { + return delegate.detail(ctx, id); + } + + /** + * Searches tags. + * + * @param params the search filters + * @return the tag list and total count + */ + public TagSearch200ResponseAllOfData search(TagSearchParams params) { + return delegate.search(ctx, params); + } + } + + /** The {@code mark} group with this client's credential supplied for you. */ + public static final class BoundMarkGroup { + + private final MarkGroup delegate; + private final QdmpContext ctx; + + private BoundMarkGroup(MarkGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Adds a mark (optionally with a rating) for a SPU. + * + * @param request the SPU to mark and optional rating + * @return the created mark's ID + */ + public MarkAdd200ResponseAllOfData add(MarkAddRequest request) { + return delegate.add(ctx, request); + } + + /** + * Lists the user's marks, paginated. + * + * @param limit page size (max 100) + * @param offset page offset (max 100) + * @return the mark list and total count + */ + public MarkList200ResponseAllOfData list(String limit, String offset) { + return delegate.list(ctx, limit, offset); + } + + /** + * Searches the user's marks by category, paginated. + * + * @param params the search filters + * @return the mark list and total count + */ + public MarkSearch200ResponseAllOfData search(MarkSearchParams params) { + return delegate.search(ctx, params); + } + + /** + * Fetches a single mark's details, including its paginated history. + * + * @param id the mark spu info ID + * @param limit history page size (max 100) + * @param offset history page offset (max 100) + * @return the mark details + */ + public MarkDetail200ResponseAllOfData detail(String id, String limit, String offset) { + return delegate.detail(ctx, id, limit, offset); + } + } + + /** The {@code wishspu} group with this client's credential supplied for you. */ + public static final class BoundWishSpuGroup { + + private final WishSpuGroup delegate; + private final QdmpContext ctx; + + private BoundWishSpuGroup(WishSpuGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Batch-adds SPUs to the user's wish list. + * + * @param request the target IDs and type + * @return the number of successful additions + */ + public WishAdd200ResponseAllOfData add(WishAddRequest request) { + return delegate.add(ctx, request); + } + + /** + * Batch-removes SPUs from the user's wish list. + * + * @param request the target IDs and type + * @return the number of successful removals + */ + public WishAdd200ResponseAllOfData cancel(WishCancelRequest request) { + return delegate.cancel(ctx, request); + } + + /** + * Lists the user's wish list, paginated. + * + * @param params the list filters + * @return the wish list and total count + */ + public WishList200ResponseAllOfData list(WishListParams params) { + return delegate.list(ctx, params); + } + } + + /** The {@code genai} group with this client's credential supplied for you. */ + public static final class BoundGenaiGroup { + + private final GenaiGroup delegate; + private final QdmpContext ctx; + + private BoundGenaiGroup(GenaiGroup delegate, QdmpContext ctx) { + this.delegate = delegate; + this.ctx = ctx; + } + + /** + * Creates an asynchronous GenAI generation task. + * + * @param request the model, conversation contents, and optional generation settings + * @return the created task's ID + */ + public GenaiGenerate200ResponseAllOfData generate(GenaiGenerateRequest request) { + return delegate.generate(ctx, request); + } + + /** + * Queries the status/result of an asynchronous GenAI generation task. + * + * @param id the task ID returned by {@link #generate} + * @return the task's current status and, once completed, its result + */ + public GenaiDetail200ResponseAllOfData detail(String id) { + return delegate.detail(ctx, id); + } + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java b/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java new file mode 100644 index 0000000..5d87d69 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java @@ -0,0 +1,30 @@ +package io.github.echotechfe.qdmp; + +/** + * The {@link AccessTokenSource} behind a {@link QdmpContext#of(String)} context: one fixed token, + * validated once at construction, that the SDK can neither renew nor replace. An HTTP 401 on this + * path is therefore surfaced to the caller as-is -- there is nothing to retry with. + */ +final class StaticAccessTokenSource implements AccessTokenSource { + + private final String accessToken; + + StaticAccessTokenSource(String accessToken) { + this.accessToken = accessToken; + } + + @Override + public String tokenForRequest() { + return accessToken; + } + + @Override + public String currentAccessToken() { + return accessToken; + } + + @Override + public String renewAfterUnauthorized(String attemptedAccessToken) { + return null; + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java new file mode 100644 index 0000000..c66cd76 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java @@ -0,0 +1,107 @@ +package io.github.echotechfe.qdmp.auth; + +/** + * The full app credential returned by {@link QdmpAuth#getAppAccessToken()}: access token, expiry, + * refresh token, and open ID. + * + *

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

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

Holds every field the server returned -- access token, expiry, refresh token, open ID -- and + * not just the two the SDK itself needs, so that a call served from the cache hands the caller + * exactly the same {@link AppAccessTokenResult} a freshly-exchanged one does. Storing only + * accessToken/expiresAt would make the cached path silently return an object missing two fields. */ public final class CachedAppToken { private final String accessToken; private final long expiresAtEpochSeconds; + private final String refreshToken; + private final String openId; /** - * Creates a new cached token. + * Creates a new cached credential. * * @param accessToken the app-level access token * @param expiresAtEpochSeconds the absolute expiry, as Unix seconds (not milliseconds, and not a * relative "seconds remaining" duration) + * @param refreshToken the refresh token the server reported alongside it, passed through + * unvalidated + * @param openId the open ID the server reported alongside it -- an empty string on this path */ - public CachedAppToken(String accessToken, long expiresAtEpochSeconds) { + public CachedAppToken( + String accessToken, long expiresAtEpochSeconds, String refreshToken, String openId) { this.accessToken = accessToken; this.expiresAtEpochSeconds = expiresAtEpochSeconds; + this.refreshToken = refreshToken; + this.openId = openId; } /** @@ -41,6 +54,24 @@ public long getExpiresAtEpochSeconds() { return expiresAtEpochSeconds; } + /** + * Returns the refresh token reported alongside the access token. + * + * @return the refresh token, or {@code null}/empty if the server omitted it + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Returns the open ID reported alongside the access token. + * + * @return the open ID -- an empty string on the app-credential path + */ + public String getOpenId() { + return openId; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -51,11 +82,13 @@ public boolean equals(Object o) { } CachedAppToken other = (CachedAppToken) o; return expiresAtEpochSeconds == other.expiresAtEpochSeconds - && Objects.equals(accessToken, other.accessToken); + && Objects.equals(accessToken, other.accessToken) + && Objects.equals(refreshToken, other.refreshToken) + && Objects.equals(openId, other.openId); } @Override public int hashCode() { - return Objects.hash(accessToken, expiresAtEpochSeconds); + return Objects.hash(accessToken, expiresAtEpochSeconds, refreshToken, openId); } } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java index d4a3e4d..4aee6e8 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,34 @@ /** * 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, or hand them to {@link + * io.github.echotechfe.qdmp.QdmpClient#withUserCredential} to have the SDK keep one alive for the + * duration of a request. */ public final class QdmpAuth { /** - * Proactive refresh buffer, in seconds: the app-level token is refreshed once fewer than this - * many seconds remain before its reported expiry, rather than waiting for it to actually expire. + * Proactive renewal buffer, in seconds: a credential is renewed once fewer than this many seconds + * remain before its reported expiry, rather than waiting for it to actually expire. Shared with + * {@link UserCredentialSession}, which applies the same buffer to user-authorization credentials. */ - 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 +72,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 +100,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 +117,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 +141,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 +174,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 +186,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 +208,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 +221,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 +261,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 +279,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/RefreshedUserToken.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java new file mode 100644 index 0000000..45a8ef9 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java @@ -0,0 +1,56 @@ +package io.github.echotechfe.qdmp.auth; + +/** + * What a {@link UserCredentialOptions#getOnRefresh()} callback receives after the SDK has renewed a + * user-authorization credential: the new access token and its new expiry, and nothing else. + * + *

There is deliberately no refresh token here. {@code POST /auth/v1/refresh} returns only {@code + * accessToken}/{@code expiresAt}; the refresh token the caller originally supplied stays valid and + * unchanged, so a callback persisting this back to storage should leave its stored refresh token + * alone. + * + *

{@link #toString()} omits the token value, so logging the callback argument cannot leak a live + * credential. + */ +public final class RefreshedUserToken { + + private final String accessToken; + private final long expiresAtEpochSeconds; + + /** + * Creates a new renewal notification. + * + * @param accessToken the newly issued user access token + * @param expiresAtEpochSeconds the absolute expiry of {@code accessToken}, as Unix seconds (not + * milliseconds, and not a relative "seconds remaining" duration) + */ + public RefreshedUserToken(String accessToken, long expiresAtEpochSeconds) { + this.accessToken = accessToken; + this.expiresAtEpochSeconds = expiresAtEpochSeconds; + } + + /** + * Returns the newly issued user access token. + * + * @return the access token + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Returns the absolute expiry of the new access token, as Unix seconds. + * + * @return the expiry, in epoch seconds + */ + public long getExpiresAtEpochSeconds() { + return expiresAtEpochSeconds; + } + + @Override + public String toString() { + return "RefreshedUserToken{accessToken=[REDACTED], expiresAtEpochSeconds=" + + expiresAtEpochSeconds + + "}"; + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java index 439b7e0..5241998 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java @@ -3,30 +3,30 @@ import java.util.Optional; /** - * Pluggable persistence for the app-level (CLIENT_CREDENTIALS) token managed by {@link QdmpAuth}. + * Pluggable persistence for the app credential (CLIENT_CREDENTIALS) managed by {@link QdmpAuth}. * The default is an in-process {@link InMemoryTokenStore}; multi-instance deployments should supply * an external implementation (e.g. Redis-backed) so that instances share one cached token instead * of each independently racing to refresh it against the qdmp API. * - *

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

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

Hand-written (not generated) so that {@link #toString()} can omit the actual token values -- * unlike the openapi-generator DTOs, whose generated {@code toString()} prints every field verbatim * and would otherwise write plaintext credentials into anything that logs this object (e.g. {@code - * logger.info("session={}", session)}). + * logger.info("credential={}", credential)}). */ -public final class Code2SessionResult { +public final class UserAccessTokenResult { private final String accessToken; private final String refreshToken; @@ -17,7 +17,7 @@ public final class Code2SessionResult { private final String openId; /** - * Creates a new session result. + * Creates a new user-authorization credential. * * @param accessToken the user-level access token * @param refreshToken the refresh token, exchangeable via {@link QdmpAuth#refreshToken(String)} @@ -25,7 +25,7 @@ public final class Code2SessionResult { * milliseconds, and not a relative "seconds remaining" duration) * @param openId the user's open ID, unique within this app */ - public Code2SessionResult( + public UserAccessTokenResult( String accessToken, String refreshToken, long expiresAtEpochSeconds, String openId) { this.accessToken = accessToken; this.refreshToken = refreshToken; @@ -71,7 +71,7 @@ public String getOpenId() { @Override public String toString() { - return "Code2SessionResult{accessToken=[REDACTED], refreshToken=[REDACTED]," + return "UserAccessTokenResult{accessToken=[REDACTED], refreshToken=[REDACTED]," + " expiresAtEpochSeconds=" + expiresAtEpochSeconds + ", openId=" diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java new file mode 100644 index 0000000..9c83024 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java @@ -0,0 +1,170 @@ +package io.github.echotechfe.qdmp.auth; + +import io.github.echotechfe.qdmp.errors.QdmpValidationError; +import java.util.OptionalLong; +import java.util.function.Consumer; + +/** + * The user-authorization credential handed to {@code QdmpClient.withUserCredential(...)}, together + * with what the SDK needs in order to keep it alive: the refresh token to renew it with, optionally + * the expiry it should renew ahead of, and optionally a callback to persist each renewal. + * + *

{@link Builder#build()} is the single gate: a missing/empty access or refresh token fails + * right there with a {@link QdmpValidationError}, before any client is created and long before any + * HTTP request could be attempted. The refresh token is mandatory precisely because without it + * there is nothing to renew with -- a caller that only has a static token should use {@code + * QdmpContext.of(token)} against the root client instead. + * + *

{@link #toString()} omits both token values, so logging the options cannot leak a live + * credential. + */ +public final class UserCredentialOptions { + + private final String accessToken; + private final String refreshToken; + private final Long expiresAtEpochSeconds; + private final Consumer onRefresh; + + private UserCredentialOptions(Builder builder) { + this.accessToken = builder.accessToken; + this.refreshToken = builder.refreshToken; + this.expiresAtEpochSeconds = builder.expiresAtEpochSeconds; + this.onRefresh = builder.onRefresh; + } + + /** + * Starts building a set of options. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the user access token to start from. + * + * @return the access token + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Returns the refresh token used to renew the access token. + * + * @return the refresh token + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Returns the absolute expiry of the supplied access token, as Unix seconds. + * + * @return the expiry in epoch seconds, or {@link OptionalLong#empty()} if the caller did not + * supply one -- in which case there is no proactive renewal and the credential is only + * renewed reactively, after an HTTP 401 + */ + public OptionalLong getExpiresAtEpochSeconds() { + return expiresAtEpochSeconds == null + ? OptionalLong.empty() + : OptionalLong.of(expiresAtEpochSeconds); + } + + /** + * Returns the callback invoked after every successful renewal. + * + * @return the callback, or {@code null} if none was supplied + */ + public Consumer getOnRefresh() { + return onRefresh; + } + + @Override + public String toString() { + return "UserCredentialOptions{accessToken=[REDACTED], refreshToken=[REDACTED]," + + " expiresAtEpochSeconds=" + + expiresAtEpochSeconds + + ", onRefresh=" + + (onRefresh == null ? "none" : "set") + + "}"; + } + + /** Builder for {@link UserCredentialOptions}. */ + public static final class Builder { + + private String accessToken; + private String refreshToken; + private Long expiresAtEpochSeconds; + private Consumer onRefresh; + + private Builder() {} + + /** + * Sets the user access token to start from. + * + * @param accessToken the access token; required and must be non-empty + * @return this builder + */ + public Builder accessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + /** + * Sets the refresh token the SDK renews the access token with. + * + * @param refreshToken the refresh token; required and must be non-empty + * @return this builder + */ + public Builder refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + /** + * Sets the absolute expiry of the supplied access token. + * + * @param expiresAtEpochSeconds the expiry as Unix seconds (not milliseconds, and not a relative + * "seconds remaining" duration); leave unset if it is unknown + * @return this builder + */ + public Builder expiresAt(long expiresAtEpochSeconds) { + this.expiresAtEpochSeconds = expiresAtEpochSeconds; + return this; + } + + /** + * Sets the callback invoked after every successful renewal, so the caller can persist the new + * access token and expiry. + * + * @param onRefresh the callback; a {@link RuntimeException} it throws propagates to the caller + * of the business method that triggered the renewal + * @return this builder + */ + public Builder onRefresh(Consumer onRefresh) { + this.onRefresh = onRefresh; + return this; + } + + /** + * Validates and builds the options. + * + * @return the built options + * @throws QdmpValidationError if the access token or the refresh token is {@code null} or empty + */ + public UserCredentialOptions build() { + if (accessToken == null || accessToken.isEmpty()) { + throw new QdmpValidationError( + "withUserCredential: \"accessToken\" is required and must be a non-empty string"); + } + if (refreshToken == null || refreshToken.isEmpty()) { + throw new QdmpValidationError( + "withUserCredential: \"refreshToken\" is required and must be a non-empty string --" + + " without it the SDK cannot renew the credential"); + } + return new UserCredentialOptions(this); + } + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java new file mode 100644 index 0000000..e0c8c67 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java @@ -0,0 +1,131 @@ +package io.github.echotechfe.qdmp.auth; + +import io.github.echotechfe.qdmp.AccessTokenSource; +import java.time.Clock; +import java.util.OptionalLong; +import java.util.function.Consumer; + +/** + * Keeps one user-authorization credential alive across calls: renews it {@link + * QdmpAuth#REFRESH_BUFFER_SECONDS} seconds ahead of a known expiry, renews it again if a call comes + * back as an HTTP 401 saying the token is invalid/expired, and hands the resulting token back to + * {@code QdmpTransport} through {@link AccessTokenSource}. + * + *

Public only because {@code QdmpUserClient} lives in a different package -- callers get one of + * these by calling {@code QdmpClient.withUserCredential(...)}, never by constructing it. + * + *

Renewals are single-flighted: every renewal happens while holding {@code renewLock}, and both + * entry points re-check the state after acquiring it, so several concurrent business calls that all + * notice the same stale token produce exactly one {@code POST /auth/v1/refresh} and exactly one + * {@code onRefresh} invocation. The refresh token itself never changes -- the endpoint does not + * return a new one -- so it is held in a final field. + */ +public final class UserCredentialSession implements AccessTokenSource { + + private final QdmpAuth auth; + private final Clock clock; + private final String refreshToken; + private final Consumer onRefresh; + private final Object renewLock = new Object(); + + private volatile UserCredentialSnapshot current; + + /** + * Creates a session around an already-obtained user-authorization credential. + * + * @param auth the auth module used to perform {@code POST /auth/v1/refresh} + * @param clock the clock used to decide whether the credential is close enough to expiry to renew + * proactively + * @param options the credential and renewal callback, already validated by {@link + * UserCredentialOptions.Builder#build()} + */ + public UserCredentialSession(QdmpAuth auth, Clock clock, UserCredentialOptions options) { + this.auth = auth; + this.clock = clock; + this.refreshToken = options.getRefreshToken(); + this.onRefresh = options.getOnRefresh(); + OptionalLong expiresAt = options.getExpiresAtEpochSeconds(); + this.current = + new UserCredentialSnapshot( + options.getAccessToken(), + this.refreshToken, + expiresAt.isPresent() ? expiresAt.getAsLong() : null); + } + + /** + * Returns an immutable view of the credential as it stands right now. + * + * @return the current snapshot; later renewals replace it rather than mutating it + */ + public UserCredentialSnapshot snapshot() { + return current; + } + + @Override + public String currentAccessToken() { + return current.getAccessToken(); + } + + @Override + public String tokenForRequest() { + UserCredentialSnapshot snapshot = current; + if (!needsProactiveRenewal(snapshot)) { + return snapshot.getAccessToken(); + } + synchronized (renewLock) { + // Re-check under the lock: while this caller was waiting, another one may have renewed + // already, in which case its result is reused instead of a second network call being made. + UserCredentialSnapshot rechecked = current; + if (!needsProactiveRenewal(rechecked)) { + return rechecked.getAccessToken(); + } + return renew().getAccessToken(); + } + } + + @Override + public String renewAfterUnauthorized(String attemptedAccessToken) { + synchronized (renewLock) { + UserCredentialSnapshot rechecked = current; + if (!rechecked.getAccessToken().equals(attemptedAccessToken)) { + // A concurrent caller already renewed after this request went out, so the 401 was about a + // token that is no longer current. Retry with theirs rather than renewing a second time. + return rechecked.getAccessToken(); + } + return renew().getAccessToken(); + } + } + + // Always called while holding renewLock. A failing refresh (10007/10008, or anything else) + // propagates untouched: the caller of the business method is the one who has to go get a fresh + // authorization code, and no business request may be attempted in the meantime. + private UserCredentialSnapshot renew() { + RefreshTokenResult result = auth.refreshToken(refreshToken); + // The refresh token is carried over unchanged: POST /auth/v1/refresh issues no new one. + UserCredentialSnapshot renewed = + new UserCredentialSnapshot( + result.getAccessToken(), refreshToken, result.getExpiresAtEpochSeconds()); + // Published before onRefresh runs, deliberately: if the callback throws (e.g. its database + // write + // fails) the business call fails with that error, but the token the server already issued is + // kept -- discarding it would waste a perfectly usable credential and force another renewal on + // the next call. + current = renewed; + if (onRefresh != null) { + onRefresh.accept( + new RefreshedUserToken(result.getAccessToken(), result.getExpiresAtEpochSeconds())); + } + return renewed; + } + + private boolean needsProactiveRenewal(UserCredentialSnapshot snapshot) { + OptionalLong expiresAt = snapshot.getExpiresAtEpochSeconds(); + if (!expiresAt.isPresent()) { + // Expiry unknown: there is nothing to renew ahead of, so the credential is only ever renewed + // reactively, when a call actually comes back 401. + return false; + } + long remaining = expiresAt.getAsLong() - clock.instant().getEpochSecond(); + return remaining <= QdmpAuth.REFRESH_BUFFER_SECONDS; + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java new file mode 100644 index 0000000..da1570b --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java @@ -0,0 +1,69 @@ +package io.github.echotechfe.qdmp.auth; + +import java.util.OptionalLong; + +/** + * An immutable point-in-time view of the user-authorization credential a {@link + * UserCredentialSession} is holding, as handed out by {@code QdmpUserClient.credential()}. + * + *

Immutable on purpose: a caller that grabs a snapshot in order to persist it must not have it + * mutate underneath them when a later call renews the credential. A renewal replaces the session's + * snapshot with a new object; every snapshot already handed out keeps describing the credential as + * it was at the moment it was taken. + * + *

{@link #toString()} omits the token value, so logging a snapshot cannot leak a live + * credential. + */ +public final class UserCredentialSnapshot { + + private final String accessToken; + private final String refreshToken; + private final Long expiresAtEpochSeconds; + + UserCredentialSnapshot(String accessToken, String refreshToken, Long expiresAtEpochSeconds) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresAtEpochSeconds = expiresAtEpochSeconds; + } + + /** + * Returns the access token held at the moment this snapshot was taken. + * + * @return the access token + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Returns the refresh token backing this credential. It never changes across renewals -- {@code + * POST /auth/v1/refresh} issues no new one -- but it is exposed here so that persisting a + * credential only needs this snapshot, rather than also reaching back to whatever the caller + * originally passed to {@code withUserCredential}. + * + * @return the refresh token + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Returns the absolute expiry of that access token, as Unix seconds. + * + * @return the expiry in epoch seconds, or {@link OptionalLong#empty()} if it is unknown -- the + * caller never supplied one and no renewal has happened yet + */ + public OptionalLong getExpiresAtEpochSeconds() { + return expiresAtEpochSeconds == null + ? OptionalLong.empty() + : OptionalLong.of(expiresAtEpochSeconds); + } + + @Override + public String toString() { + return "UserCredentialSnapshot{accessToken=[REDACTED], refreshToken=[REDACTED]," + + " expiresAtEpochSeconds=" + + expiresAtEpochSeconds + + "}"; + } +} 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..e38a81b 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); } @@ -103,7 +103,7 @@ void refreshToken_negativeExpiresAt_throws() { * 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); } @@ -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..444c897 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("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 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,7 +55,7 @@ void code2Session_returnsCode2SessionResult_withMatchingGetters() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult session = client.auth().getUserAccessToken("some-code"); assertThat(session.getAccessToken()).isEqualTo(SENTINEL_ACCESS_TOKEN); assertThat(session.getRefreshToken()).isEqualTo(SENTINEL_REFRESH_TOKEN); @@ -63,7 +64,7 @@ void code2Session_returnsCode2SessionResult_withMatchingGetters() { } @Test - void code2Session_toString_doesNotLeakAccessTokenOrRefreshToken() { + void getUserAccessToken_toString_doesNotLeakAccessTokenOrRefreshToken() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -75,7 +76,7 @@ void code2Session_toString_doesNotLeakAccessTokenOrRefreshToken() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult session = client.auth().getUserAccessToken("some-code"); assertThat(session.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); assertThat(session.toString()).doesNotContain(SENTINEL_REFRESH_TOKEN); @@ -90,7 +91,7 @@ void code2Session_toString_doesNotLeakAccessTokenOrRefreshToken() { * 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,7 +106,7 @@ void code2Session_toString_doesNotLeakRawCrLfInOpenId() { + "\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult session = client.auth().getUserAccessToken("some-code"); assertThat(session.toString()).doesNotContain("\r").doesNotContain("\n"); // Reverse-direction guard: getOpenId() must still return the raw value untouched. 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 82% 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..73fc976 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_andReturnsFullSession() + throws Exception { server.enqueue( new MockResponse() .setResponseCode(200) @@ -52,7 +53,7 @@ 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 session = client.auth().getUserAccessToken("wx-login-code-abc"); assertThat(session.getAccessToken()).isEqualTo("user-access-token"); assertThat(session.getExpiresAtEpochSeconds()).isEqualTo(1900000000L); @@ -65,7 +66,7 @@ void code2Session_success_sendsAuthorizationCodeGrant_andReturnsFullSession() th } @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,19 +198,20 @@ 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("wx-login-code-abc")) .isInstanceOf(QdmpTransportException.class); } @@ -219,7 +221,7 @@ void code2Session_successCode_withNullData_throwsQdmpTransportException() { * {@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("wx-login-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 78% 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..929d103 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,15 @@ 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 + * {@link QdmpAuth#getUserAccessToken}'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. */ -class QdmpAuthCode2SessionFieldValidationTest { +class QdmpAuthUserAccessTokenFieldValidationTest { private MockWebServer server; @@ -36,7 +36,7 @@ void stopServer() throws IOException { } @Test - void code2Session_missingRefreshToken_throws() { + void getUserAccessToken_missingRefreshToken_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -45,12 +45,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 +60,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 +74,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 +88,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/auth/QdmpUserCredentialTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java new file mode 100644 index 0000000..69b838a --- /dev/null +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java @@ -0,0 +1,703 @@ +package io.github.echotechfe.qdmp.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.echotechfe.qdmp.QdmpClient; +import io.github.echotechfe.qdmp.QdmpUserClient; +import io.github.echotechfe.qdmp.errors.QdmpApiError; +import io.github.echotechfe.qdmp.errors.QdmpValidationError; +import io.github.echotechfe.qdmp.generated.GenaiGenerateRequest; +import io.github.echotechfe.qdmp.generated.GenaiGenerateRequestContentsInner; +import io.github.echotechfe.qdmp.generated.GenaiGenerateRequestContentsInnerPartsInner; +import io.github.echotechfe.qdmp.generated.UserMe200ResponseAllOfData; +import io.github.echotechfe.qdmp.testsupport.MutableClock; +import io.github.echotechfe.qdmp.testsupport.TestClients; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * {@code qdmp.withUserCredential(UserCredentialOptions)} (CONTRACT.md §2): a user-authorization + * credential wrapper that, unlike the one-shot {@code auth().getUserAccessToken}/{@code + * auth().refreshToken} calls, keeps the access token alive across calls -- refreshing it 300s ahead + * of a known expiry, and reactively on an HTTP 401 with business code 10005/10006, retrying the + * failed business call exactly once with the new token. + */ +class QdmpUserCredentialTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String REFRESH_PATH = "/auth/v1/refresh"; + private static final String ME_PATH = "/user/v1/me"; + private static final String GENAI_GENERATE_PATH = "/genai/v1/generate"; + private static final String SENTINEL_ACCESS_TOKEN = "sentinel-user-access-token-xyz"; + private static final String SENTINEL_REFRESH_TOKEN = "sentinel-user-refresh-token-abc"; + + private MockWebServer server; + + @BeforeEach + void startServer() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void stopServer() throws IOException { + server.shutdown(); + } + + // ---- MockResponse builders ------------------------------------------------- + + private static MockResponse refreshSuccess(String accessToken, long expiresAtEpochSeconds) { + return new MockResponse() + .setResponseCode(200) + .setBody( + "{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"accessToken\":\"" + + accessToken + + "\",\"expiresAt\":\"" + + expiresAtEpochSeconds + + "\"}}"); + } + + private static MockResponse refreshFailure(int businessCode) { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"code\":" + businessCode + ",\"message\":\"refreshToken invalid\"}"); + } + + private static MockResponse meSuccess(String userId) { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"id\":\"" + userId + "\"}}"); + } + + private static MockResponse http401(int businessCode) { + return new MockResponse() + .setResponseCode(401) + .setBody("{\"code\":" + businessCode + ",\"message\":\"access_token invalid\"}"); + } + + private static MockResponse businessError200(int businessCode) { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"code\":" + businessCode + ",\"message\":\"business error\"}"); + } + + private static MockResponse serverError5xx() { + return new MockResponse() + .setResponseCode(500) + .setBody("{\"code\":13,\"message\":\"internal\"}"); + } + + private static MockResponse genaiSuccess(String taskId) { + return new MockResponse() + .setResponseCode(200) + .setBody("{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"id\":\"" + taskId + "\"}}"); + } + + private static GenaiGenerateRequest genaiRequest() { + return new GenaiGenerateRequest() + .model("google/gemini-3.1-flash-image-preview") + .addContentsItem( + new GenaiGenerateRequestContentsInner() + .role("user") + .addPartsItem(new GenaiGenerateRequestContentsInnerPartsInner().text("hello"))); + } + + // ---- 2.3.1 proactive refresh (300s buffer, same as app credential) -------- + + @Test + void proactiveRefresh_whenExpiresAtWithinBuffer_refreshesBeforeBusinessCall() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + long almostExpired = t0.getEpochSecond() + 200; // inside the 300s buffer + server.enqueue(refreshSuccess("refreshed-token-1", t0.getEpochSecond() + 7200)); + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("refresh-token-1") + .expiresAt(almostExpired) + .build()); + + UserMe200ResponseAllOfData me = uc.user().me(); + + assertThat(me.getId()).isEqualTo("user-1"); + assertThat(server.getRequestCount()).isEqualTo(2); + RecordedRequest refreshRequest = server.takeRequest(); + assertThat(refreshRequest.getPath()).isEqualTo(REFRESH_PATH); + assertThat(refreshRequest.getHeader("access-token")).isNull(); + JsonNode refreshBody = JSON.readTree(refreshRequest.getBody().readUtf8()); + assertThat(refreshBody.get("refreshToken").asText()).isEqualTo("refresh-token-1"); + RecordedRequest meRequest = server.takeRequest(); + assertThat(meRequest.getPath()).isEqualTo(ME_PATH); + assertThat(meRequest.getHeader("access-token")).isEqualTo("refreshed-token-1"); + } + + @Test + void noProactiveRefresh_whenExpiresAtOutsideBuffer_callsBusinessDirectly() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + long farFromExpiry = t0.getEpochSecond() + 400; // outside the 300s buffer + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("still-valid-token") + .refreshToken("refresh-token-1") + .expiresAt(farFromExpiry) + .build()); + + uc.user().me(); + + assertThat(server.getRequestCount()).isEqualTo(1); + RecordedRequest request = server.takeRequest(); + assertThat(request.getPath()).isEqualTo(ME_PATH); + } + + @Test + void noExpiresAt_skipsProactiveRefresh_reliesOnlyOn401() throws Exception { + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("no-expiry-known-token") + .refreshToken("refresh-token-1") + .build()); + + uc.user().me(); + + assertThat(server.getRequestCount()).isEqualTo(1); + assertThat(server.takeRequest().getPath()).isEqualTo(ME_PATH); + } + + // ---- 2.3.2 / 2.3.3 refresh action + onRefresh callback --------------------- + + @Test + void onRefresh_isCalledWithNewAccessTokenAndExpiry_afterProactiveRefresh() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + long expiresAt = t0.getEpochSecond() + 200; + long newExpiresAt = t0.getEpochSecond() + 7200; + server.enqueue(refreshSuccess("new-token-for-callback", newExpiresAt)); + server.enqueue(meSuccess("user-1")); + AtomicReference captured = new AtomicReference<>(); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("refresh-token-1") + .expiresAt(expiresAt) + .onRefresh(captured::set) + .build()); + + uc.user().me(); + + assertThat(captured.get()).isNotNull(); + assertThat(captured.get().getAccessToken()).isEqualTo("new-token-for-callback"); + assertThat(captured.get().getExpiresAtEpochSeconds()).isEqualTo(newExpiresAt); + } + + @Test + void refreshToken_staysUnchanged_acrossMultipleRefreshes() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + server.enqueue(refreshSuccess("token-round-1", t0.getEpochSecond() + 350)); + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("original-refresh-token") + .expiresAt(t0.getEpochSecond() + 200) + .build()); + uc.user().me(); + final RecordedRequest firstRefresh = server.takeRequest(); + server.takeRequest(); // the me() call following the first refresh + + // Advance past the second token's buffer too, forcing a second proactive refresh. + clock.advanceSeconds(100); + server.enqueue(refreshSuccess("token-round-2", clock.instant().getEpochSecond() + 7200)); + server.enqueue(meSuccess("user-1")); + + uc.user().me(); + RecordedRequest secondRefresh = server.takeRequest(); + + JsonNode firstBody = JSON.readTree(firstRefresh.getBody().readUtf8()); + JsonNode secondBody = JSON.readTree(secondRefresh.getBody().readUtf8()); + assertThat(firstBody.get("refreshToken").asText()).isEqualTo("original-refresh-token"); + assertThat(secondBody.get("refreshToken").asText()).isEqualTo("original-refresh-token"); + } + + // ---- 2.3.4 / 2.3.5 401 retry, exactly once --------------------------------- + + @Test + void retry401_code10006_refreshesOnce_andRetriesBusinessCallExactlyOnce() throws Exception { + server.enqueue(http401(10006)); + server.enqueue(refreshSuccess("token-after-401", 9_999_999_999L)); + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("stale-token") + .refreshToken("refresh-token-1") + .build()); + + UserMe200ResponseAllOfData me = uc.user().me(); + + assertThat(me.getId()).isEqualTo("user-1"); + assertThat(server.getRequestCount()).isEqualTo(3); + RecordedRequest firstMe = server.takeRequest(); + assertThat(firstMe.getPath()).isEqualTo(ME_PATH); + assertThat(firstMe.getHeader("access-token")).isEqualTo("stale-token"); + RecordedRequest refresh = server.takeRequest(); + assertThat(refresh.getPath()).isEqualTo(REFRESH_PATH); + RecordedRequest retriedMe = server.takeRequest(); + assertThat(retriedMe.getPath()).isEqualTo(ME_PATH); + assertThat(retriedMe.getHeader("access-token")).isEqualTo("token-after-401"); + } + + @Test + void retry401_code10005_refreshesOnce_andRetriesBusinessCallExactlyOnce() throws Exception { + server.enqueue(http401(10005)); + server.enqueue(refreshSuccess("token-after-401-b", 9_999_999_999L)); + server.enqueue(meSuccess("user-1")); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("stale-token") + .refreshToken("refresh-token-1") + .build()); + + uc.user().me(); + + assertThat(server.getRequestCount()).isEqualTo(3); + } + + @Test + void retry401_secondFailureAfterRetry_propagatesImmediately_noSecondRefresh() throws Exception { + server.enqueue(http401(10006)); + server.enqueue(refreshSuccess("token-still-rejected", 9_999_999_999L)); + server.enqueue(http401(10006)); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("stale-token") + .refreshToken("refresh-token-1") + .build()); + + assertThatThrownBy(uc.user()::me) + .isInstanceOf(QdmpApiError.class) + .satisfies( + e -> { + QdmpApiError err = (QdmpApiError) e; + assertThat(err.getCode()).isEqualTo("10006"); + assertThat(err.getHttpStatus()).isEqualTo(401); + }); + // Exactly: first business attempt + one refresh + retried business attempt. No further + // refresh/retry loop even though the retried call failed with the same 401/10006 shape. + assertThat(server.getRequestCount()).isEqualTo(3); + } + + // ---- 2.3.6 non-retryable failures must never trigger a refresh ------------ + + @Test + void http5xx_neverTriggersRefresh() { + server.enqueue(serverError5xx()); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("some-token") + .refreshToken("refresh-token-1") + .build()); + + assertThatThrownBy(uc.user()::me).isInstanceOf(QdmpApiError.class); + + assertThat(server.getRequestCount()).isEqualTo(1); + } + + @Test + void nonAuthBusinessErrorAtHttp200_neverTriggersRefresh() { + server.enqueue(businessError200(10002)); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("some-token") + .refreshToken("refresh-token-1") + .build()); + + assertThatThrownBy(uc.user()::me) + .isInstanceOf(QdmpApiError.class) + .satisfies(e -> assertThat(((QdmpApiError) e).getCode()).isEqualTo("10002")); + assertThat(server.getRequestCount()).isEqualTo(1); + } + + @Test + void http401_withUnrelatedBusinessCode_neverTriggersRefresh() { + // 401 alone is not the refresh trigger -- only 401 + {10005,10006} is. A 401 carrying some + // other business code must be surfaced as-is. + server.enqueue(http401(10001)); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("some-token") + .refreshToken("refresh-token-1") + .build()); + + assertThatThrownBy(uc.user()::me) + .isInstanceOf(QdmpApiError.class) + .satisfies( + e -> { + QdmpApiError err = (QdmpApiError) e; + assertThat(err.getCode()).isEqualTo("10001"); + assertThat(err.getHttpStatus()).isEqualTo(401); + }); + assertThat(server.getRequestCount()).isEqualTo(1); + } + + // ---- 2.3.7 refresh itself failing ----------------------------------------- + + @Test + void refreshItselfFails_propagatesRefreshError_andNeverRetriesBusinessCall() { + server.enqueue(http401(10006)); + server.enqueue(refreshFailure(10008)); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("stale-token") + .refreshToken("dead-refresh-token") + .build()); + + assertThatThrownBy(uc.user()::me) + .isInstanceOf(QdmpApiError.class) + .satisfies(e -> assertThat(((QdmpApiError) e).getCode()).isEqualTo("10008")); + // Only the original business call plus the failed refresh -- no second business attempt. + assertThat(server.getRequestCount()).isEqualTo(2); + } + + // ---- 2.3.3 onRefresh throwing must not roll back the already-updated token ---- + + private static final class BoomException extends RuntimeException { + BoomException(String message) { + super(message); + } + } + + @Test + void onRefreshThrows_businessCallFails_butNewTokenIsPersistedForNextCall() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + server.enqueue(refreshSuccess("post-refresh-token", t0.getEpochSecond() + 7200)); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("refresh-token-1") + .expiresAt(t0.getEpochSecond() + 100) + .onRefresh( + t -> { + throw new BoomException("db write failed"); + }) + .build()); + + assertThatThrownBy(uc.user()::me).isInstanceOf(BoomException.class); + // The refresh itself must have happened (and only once); the business call must never have + // been sent because onRefresh's failure aborts the whole call. + assertThat(server.getRequestCount()).isEqualTo(1); + assertThat(server.takeRequest().getPath()).isEqualTo(REFRESH_PATH); + + // A second call, with the clock now far past the old expiry, must go straight to the + // business endpoint using the token that was already swapped in -- proving it was not rolled + // back just because onRefresh blew up. + server.enqueue(meSuccess("user-1")); + uc.user().me(); + + assertThat(server.getRequestCount()).isEqualTo(2); + RecordedRequest secondCall = server.takeRequest(); + assertThat(secondCall.getPath()).isEqualTo(ME_PATH); + assertThat(secondCall.getHeader("access-token")).isEqualTo("post-refresh-token"); + } + + // ---- 2.3.8 concurrent single-flight refresh -------------------------------- + + @Test + void concurrentBusinessCalls_refreshHappensOnlyOnce() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + int threadCount = 8; + server.enqueue( + refreshSuccess("single-flight-user-token", t0.getEpochSecond() + 7200) + .setBodyDelay(200, TimeUnit.MILLISECONDS)); + for (int i = 0; i < threadCount; i++) { + server.enqueue(meSuccess("user-1")); + } + AtomicInteger onRefreshCount = new AtomicInteger(); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("refresh-token-1") + .expiresAt(t0.getEpochSecond() + 100) + .onRefresh(t -> onRefreshCount.incrementAndGet()) + .build()); + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + List results = new CopyOnWriteArrayList<>(); + + try { + for (int i = 0; i < threadCount; i++) { + pool.submit( + () -> { + ready.countDown(); + try { + start.await(); + results.add(uc.user().me().getId()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + boolean finished = done.await(10, TimeUnit.SECONDS); + + assertThat(finished).as("all callers finished within timeout").isTrue(); + assertThat(results).hasSize(threadCount).containsOnly("user-1"); + assertThat(onRefreshCount.get()).as("onRefresh must fire exactly once").isEqualTo(1); + + int refreshRequestCount = 0; + int meRequestCount = 0; + for (int i = 0; i < server.getRequestCount(); i++) { + RecordedRequest request = server.takeRequest(); + if (REFRESH_PATH.equals(request.getPath())) { + refreshRequestCount++; + } else if (ME_PATH.equals(request.getPath())) { + meRequestCount++; + } + } + assertThat(refreshRequestCount) + .as( + "only a single refresh request should reach the server despite %d concurrent callers", + threadCount) + .isEqualTo(1); + assertThat(meRequestCount).isEqualTo(threadCount); + } finally { + pool.shutdownNow(); + } + } + + // ---- 2.3.9 credential() is a read-only snapshot ---------------------------- + + @Test + void credential_reflectsLatestToken_andEarlierSnapshotIsUnaffectedByLaterRefresh() { + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken("refresh-token-1") + .build()); + + var snapshotBeforeRefresh = uc.credential(); + assertThat(snapshotBeforeRefresh.getAccessToken()).isEqualTo("initial-token"); + + server.enqueue(http401(10006)); + server.enqueue(refreshSuccess("refreshed-op-token", 9_999_999_999L)); + server.enqueue(meSuccess("user-1")); + uc.user().me(); + + var snapshotAfterRefresh = uc.credential(); + assertThat(snapshotAfterRefresh.getAccessToken()).isEqualTo("refreshed-op-token"); + // The object obtained before the refresh must not have mutated in place. + assertThat(snapshotBeforeRefresh.getAccessToken()).isEqualTo("initial-token"); + } + + // ---- 2.2 local input validation -------------------------------------------- + + @Test + void missingRefreshToken_null_failsLocally_sendsNoHttpRequest() { + QdmpClient qdmp = TestClients.create(server); + + assertThatThrownBy( + () -> { + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("at-valid") + .refreshToken(null) + .build()); + uc.user().me(); + }) + .isInstanceOf(QdmpValidationError.class); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + @Test + void emptyRefreshToken_failsLocally_sendsNoHttpRequest() { + QdmpClient qdmp = TestClients.create(server); + + assertThatThrownBy( + () -> { + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("at-valid") + .refreshToken("") + .build()); + uc.user().me(); + }) + .isInstanceOf(QdmpValidationError.class); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + @Test + void missingAccessToken_null_failsLocally_sendsNoHttpRequest() { + QdmpClient qdmp = TestClients.create(server); + + assertThatThrownBy( + () -> { + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken(null) + .refreshToken("rt-valid") + .build()); + uc.user().me(); + }) + .isInstanceOf(QdmpValidationError.class); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + @Test + void emptyAccessToken_failsLocally_sendsNoHttpRequest() { + QdmpClient qdmp = TestClients.create(server); + + assertThatThrownBy( + () -> { + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("") + .refreshToken("rt-valid") + .build()); + uc.user().me(); + }) + .isInstanceOf(QdmpValidationError.class); + assertThat(server.getRequestCount()).isEqualTo(0); + } + + // ---- 2.5 genai group: same auto-renewal, genai-specific headers ----------- + + @Test + void genai_retry401_exactlyOnce_stillUsesGenaiHeaders_notStandardHeaders() throws Exception { + server.enqueue(http401(10006)); + server.enqueue(refreshSuccess("genai-token-after-401", 9_999_999_999L)); + server.enqueue(genaiSuccess("task-1")); + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("stale-genai-token") + .refreshToken("refresh-token-1") + .build()); + + uc.genai().generate(genaiRequest()); + + assertThat(server.getRequestCount()).isEqualTo(3); + RecordedRequest firstAttempt = server.takeRequest(); + assertThat(firstAttempt.getPath()).isEqualTo(GENAI_GENERATE_PATH); + assertThat(firstAttempt.getHeader("x-openapi-access-token")).isEqualTo("stale-genai-token"); + RecordedRequest refresh = server.takeRequest(); + assertThat(refresh.getPath()).isEqualTo(REFRESH_PATH); + RecordedRequest retriedAttempt = server.takeRequest(); + assertThat(retriedAttempt.getPath()).isEqualTo(GENAI_GENERATE_PATH); + assertThat(retriedAttempt.getHeader("x-openapi-access-token")) + .isEqualTo("genai-token-after-401"); + assertThat(retriedAttempt.getHeader("access-token")).isNull(); + } + + // ---- 2.3.10 debug printing must never leak the credential ------------------ + + @Test + void userCredentialOptions_toString_doesNotLeakAccessTokenOrRefreshToken() { + UserCredentialOptions options = + UserCredentialOptions.builder() + .accessToken(SENTINEL_ACCESS_TOKEN) + .refreshToken(SENTINEL_REFRESH_TOKEN) + .build(); + + assertThat(options.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); + assertThat(options.toString()).doesNotContain(SENTINEL_REFRESH_TOKEN); + } + + @Test + void credential_toString_doesNotLeakAccessToken() { + QdmpClient qdmp = TestClients.create(server); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken(SENTINEL_ACCESS_TOKEN) + .refreshToken(SENTINEL_REFRESH_TOKEN) + .build()); + + var snapshot = uc.credential(); + + assertThat(snapshot.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); + } + + @Test + void refreshedUserToken_toString_doesNotLeakAccessToken() throws Exception { + Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); + MutableClock clock = MutableClock.startingAt(t0); + server.enqueue(refreshSuccess(SENTINEL_ACCESS_TOKEN, t0.getEpochSecond() + 7200)); + server.enqueue(meSuccess("user-1")); + AtomicReference captured = new AtomicReference<>(); + QdmpClient qdmp = TestClients.create(server, null, clock); + QdmpUserClient uc = + qdmp.withUserCredential( + UserCredentialOptions.builder() + .accessToken("initial-token") + .refreshToken(SENTINEL_REFRESH_TOKEN) + .expiresAt(t0.getEpochSecond() + 100) + .onRefresh(captured::set) + .build()); + + uc.user().me(); + + assertThat(captured.get().toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); + } +} 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/src/auth.ts b/node/src/auth.ts index f879a87..787408d 100644 --- a/node/src/auth.ts +++ b/node/src/auth.ts @@ -1,52 +1,33 @@ /** * Token lifecycle management: * - * - getAccessToken(): app-level (CLIENT_CREDENTIALS) token, auto-cached via - * the pluggable TokenStore, single-flight refresh, proactively renewed - * 300 seconds before real expiry (not reactive to a 401). - * - code2Session(): one-shot AUTHORIZATION_CODE exchange. Never cached — - * the calling backend owns per-end-user session persistence. + * - getAppAccessToken(): 应用凭证 (CLIENT_CREDENTIALS), auto-cached via the + * pluggable TokenStore, single-flight refresh, proactively renewed 300 + * seconds before real expiry (not reactive to a 401). + * - getUserAccessToken(): one-shot AUTHORIZATION_CODE exchange producing a + * 用户授权凭证. Never cached — the calling backend owns per-end-user + * credential persistence. * - refreshToken(): one-shot refresh of a caller-supplied refreshToken. * Never cached either. */ -import {inspect} from 'node:util'; - import type {HttpClient} from './http.js'; -import type {AppTokenData, RefreshTokenData, SessionData} from './types.js'; +import type { + AppAccessTokenData, + RefreshTokenData, + UserAccessTokenData, +} from './types.js'; import type {StoredToken, TokenStore} from './token-store.js'; import {getRouteMeta} from './generated/route-meta.js'; import {QdmpTransportError, QdmpValidationError} from './errors.js'; +import {withRedactedInspect} from './redact.js'; /** Refresh this many seconds before the token's real expiresAt. */ const APP_TOKEN_REFRESH_BUFFER_SECONDS = 300; -/** - * Attaches a non-enumerable [util.inspect.custom] renderer to `value` so - * that `console.log(value)`/`util.inspect(value)` — an easy accidental - * debug-print path — never shows the raw accessToken/refreshToken. This is - * deliberately done via Object.defineProperty on an already-constructed, - * already-typed value rather than as an object-literal property: adding - * `[inspect.custom]: ...` directly to a `SessionData`/`RefreshTokenData` - * literal would trip TypeScript's excess-property check (neither interface - * declares a symbol-keyed member). - * - * This must never affect JSON.stringify(value) or direct property access - * (value.accessToken): JSON.stringify only consults toJSON(), not - * util.inspect.custom, and the defined property here is non-enumerable so - * it does not add a spurious key to the object either. Persisting the real - * values is the entire point of code2Session()/refreshToken() existing, so - * that path must stay untouched. - */ -function withRedactedInspect( - value: T, - render: () => string, -): T { - Object.defineProperty(value, inspect.custom, { - value: render, - enumerable: false, - }); - return value; -} +/** A still-unvalidated {@link AppAccessTokenData} exactly as it arrived off + * the wire: every field is optional here because a business-success envelope + * is no guarantee that `data` actually carries them. */ +type RawAppAccessTokenData = Partial; /** Guards against a malformed "success" response: HTTP 200 + business * code='0' but with a missing/empty accessToken, or an expiresAt that can't @@ -84,12 +65,12 @@ function assertWellFormedTokenResponse( } /** Guards against a syntactically well-formed but already-expired-on-arrival - * expiresAt in a one-shot (never-cached) code2Session()/refreshToken() + * expiresAt in a one-shot (never-cached) getUserAccessToken()/refreshToken() * response. assertWellFormedTokenResponse() only checks that expiresAt * parses as a non-negative safe integer — a value like "1" (1970-01-01) - * passes that check but describes a session/token that is already dead, and + * passes that check but describes a credential that is already dead, and * must not be handed back to the caller as if it were usable (they might - * persist or start relying on it). Unlike getAccessToken()'s cached path, + * persist or start relying on it). Unlike getAppAccessToken()'s cached path, * this only rejects an already-past expiresAt, not one merely within the * app-token refresh buffer — these one-shot results are never cached by the * SDK, so there is no cache-freshness policy to apply here. */ @@ -105,15 +86,16 @@ function assertNotAlreadyExpired( } } -/** Guards against a malformed code2Session() response: HTTP 200 + business - * code='0' but with a missing/empty refreshToken or openId. Unlike +/** Guards against a malformed getUserAccessToken() response: HTTP 200 + + * business code='0' but with a missing/empty refreshToken or openId. Unlike * accessToken/expiresAt (validated by assertWellFormedTokenResponse() and - * shared with getAccessToken()/refreshToken()), refreshToken/openId are only - * present in the AUTHORIZATION_CODE grant's response, so this check is - * intentionally separate and only ever called from code2Session(). Must be + * shared with getAppAccessToken()/refreshToken()), refreshToken/openId only + * carry meaning in the AUTHORIZATION_CODE grant's response, so this check is + * intentionally separate and only ever called from getUserAccessToken() — + * the CLIENT_CREDENTIALS grant legitimately returns an empty openId. Must be * called before the result is resolved to the caller — otherwise an empty * openId could silently end up persisted as a user identifier. */ -function assertWellFormedSessionResponse( +function assertWellFormedUserAccessTokenResponse( data: {refreshToken?: string; openId?: string} | undefined, context: string, ): void { @@ -133,6 +115,29 @@ function assertWellFormedSessionResponse( } } +/** Renders a cached entry as the complete 应用凭证 callers see. Both the + * cache-hit and the just-exchanged paths go through here, so neither can + * return a credential that is less complete than the other. */ +function toAppAccessTokenData(token: StoredToken): AppAccessTokenData { + const credential: AppAccessTokenData = { + accessToken: token.accessToken, + expiresAt: token.expiresAt, + refreshToken: token.refreshToken ?? '', + openId: token.openId ?? '', + }; + return withRedactedInspect( + credential, + // JSON.stringify (not raw template interpolation) for the non-secret + // fields: expiresAt is our own validated digit-only string, but openId + // is server-controlled and not validated for this grant — an embedded + // CR/LF there would otherwise become a raw line break in this debug + // rendering, the same log-injection class of bug already fixed for + // message/code/requestId elsewhere. + () => + `AppAccessTokenData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(credential.expiresAt)}, openId: ${JSON.stringify(credential.openId)} }`, + ); +} + export interface AuthModuleConfig { http: HttpClient; appId: string; @@ -145,7 +150,7 @@ export class AuthModule { private readonly appId: string; private readonly appSecret: string; private readonly tokenStore: TokenStore; - private inFlight: Promise | undefined; + private inFlight: Promise | undefined; /** In-process mirror of the last token this instance itself fetched or * observed as fresh, set synchronously the moment a refresh HTTP call * resolves (see refreshAppToken()) — i.e. before awaiting tokenStore.set() @@ -164,14 +169,14 @@ export class AuthModule { this.tokenStore = config.tokenStore; } - async getAccessToken(): Promise { + async getAppAccessToken(): Promise { if (this.cachedToken && this.isFresh(this.cachedToken)) { - return this.cachedToken.accessToken; + return toAppAccessTokenData(this.cachedToken); } const cached = await Promise.resolve(this.tokenStore.get()); if (cached && this.isFresh(cached)) { this.cachedToken = cached; - return cached.accessToken; + return toAppAccessTokenData(cached); } return this.refreshAppToken(); } @@ -189,28 +194,39 @@ export class AuthModule { * * Re-checks `this.cachedToken` freshness right before deciding whether to * start a new flight. This closes a race with an async/remote TokenStore: - * a caller can reach here (via `getAccessToken()`'s `await + * a caller can reach here (via `getAppAccessToken()`'s `await * tokenStore.get()`) *after* another caller's in-flight refresh has * already completed and cleared `inFlight` — if that other refresh * happened to run entirely while this caller's `tokenStore.get()` was * still pending, `this.cachedToken` was already updated in the meantime * and must be honored here instead of blindly starting a second, * redundant CLIENT_CREDENTIALS exchange. */ - private refreshAppToken(): Promise { + private refreshAppToken(): Promise { if (this.cachedToken && this.isFresh(this.cachedToken)) { - return Promise.resolve(this.cachedToken.accessToken); + return Promise.resolve(toAppAccessTokenData(this.cachedToken)); } if (!this.inFlight) { this.inFlight = this.requestClientCredentialsToken() - .then(async token => { - assertWellFormedTokenResponse(token, 'auth.getAccessToken'); + .then(async raw => { + assertWellFormedTokenResponse(raw, 'auth.getAppAccessToken'); + // refreshToken/openId are deliberately not validated here: the + // CLIENT_CREDENTIALS grant really does return an empty openId, + // and its refreshToken is not used by the SDK's own renewal + // strategy (which re-runs the grant instead). Whatever the server + // sent is passed through as-is. + const token: StoredToken = { + accessToken: raw.accessToken as string, + expiresAt: raw.expiresAt as string, + refreshToken: raw.refreshToken, + openId: raw.openId, + }; // A token that is syntactically well-formed can still be // expired-on-arrival (e.g. a server bug returns an `expiresAt` // far in the past). Such a token must never be cached or handed // back to the caller as if it were freshly usable. if (!this.isFresh(token)) { throw new QdmpTransportError( - 'auth.getAccessToken: server response was a business-success envelope but the returned token is already expired (or within the refresh buffer) on arrival', + 'auth.getAppAccessToken: server response was a business-success envelope but the returned token is already expired (or within the refresh buffer) on arrival', ); } // Only trust the local mirror once the store write actually @@ -222,7 +238,7 @@ export class AuthModule { // is left untouched and this call's own promise rejects too. await Promise.resolve(this.tokenStore.set(token)); this.cachedToken = token; - return token.accessToken; + return toAppAccessTokenData(token); }) .finally(() => { this.inFlight = undefined; @@ -231,9 +247,9 @@ export class AuthModule { return this.inFlight; } - private requestClientCredentialsToken(): Promise { + private requestClientCredentialsToken(): Promise { const route = getRouteMeta('authToken'); - return this.http.request({ + return this.http.request({ method: route.method, path: route.path, authScheme: route.authScheme, @@ -245,14 +261,14 @@ export class AuthModule { }); } - async code2Session(code: string): Promise { + async getUserAccessToken(code: string): Promise { if (typeof code !== 'string' || code.length === 0) { throw new QdmpValidationError( - 'code2Session: "code" is required for grantType=AUTHORIZATION_CODE and must be a non-empty string', + 'getUserAccessToken: "code" is required for grantType=AUTHORIZATION_CODE and must be a non-empty string', ); } const route = getRouteMeta('authToken'); - const session = await this.http.request({ + const credential = await this.http.request({ method: route.method, path: route.path, authScheme: route.authScheme, @@ -263,11 +279,14 @@ export class AuthModule { code, }, }); - assertWellFormedTokenResponse(session, 'auth.code2Session'); - assertWellFormedSessionResponse(session, 'auth.code2Session'); - assertNotAlreadyExpired(session, 'auth.code2Session'); + assertWellFormedTokenResponse(credential, 'auth.getUserAccessToken'); + assertWellFormedUserAccessTokenResponse( + credential, + 'auth.getUserAccessToken', + ); + assertNotAlreadyExpired(credential, 'auth.getUserAccessToken'); return withRedactedInspect( - session, + credential, // JSON.stringify (not raw template interpolation) for the non-secret // fields: expiresAt is our own validated digit-only string, but openId // is server-controlled and only validated as "non-empty" — an @@ -275,7 +294,7 @@ export class AuthModule { // this debug rendering, the same log-injection class of bug already // fixed for message/code/requestId elsewhere. () => - `SessionData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(session.expiresAt)}, openId: ${JSON.stringify(session.openId)} }`, + `UserAccessTokenData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(credential.expiresAt)}, openId: ${JSON.stringify(credential.openId)} }`, ); } diff --git a/node/src/client.ts b/node/src/client.ts index aa1e462..c11eb76 100644 --- a/node/src/client.ts +++ b/node/src/client.ts @@ -11,6 +11,8 @@ import {UserGroup} from './groups/user.js'; import {WishSpuGroup} from './groups/wishspu.js'; import {HttpClient} from './http.js'; import {InMemoryTokenStore, type TokenStore} from './token-store.js'; +import {QdmpUserClient} from './user-client.js'; +import type {UserCredentialOptions} from './user-credential.js'; /** The qdmp OpenAPI host (see shared/openapi.yaml `servers[0]`). */ const DEFAULT_BASE_URL = 'https://openapi.qiandao.com'; @@ -97,11 +99,18 @@ export class QdmpClient { readonly wishspu: WishSpuGroup; readonly genai: GenaiGroup; + private readonly http: HttpClient; + private readonly appId: string; + private readonly qdmpVersion: string; + constructor(options: QdmpClientOptions) { const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; assertSafeBaseUrl(baseUrl); const qdmpVersion = options.qdmpVersion ?? '1.0'; const http = new HttpClient({baseUrl, dispatcher: options.dispatcher}); + this.http = http; + this.appId = options.appId; + this.qdmpVersion = qdmpVersion; this.auth = new AuthModule({ http, @@ -119,4 +128,21 @@ export class QdmpClient { this.wishspu = new WishSpuGroup(deps); this.genai = new GenaiGroup(deps); } + + /** Binds one 用户授权凭证 to a client whose business methods take no + * QdmpContext and renew the credential on their own — proactively within + * 300 seconds of expiry, and reactively on an HTTP 401 carrying business + * code 10005/10006 (renewed once, request replayed once). + * + * The groups on `this` are unaffected: they keep requiring an explicit + * ctx and never renew anything. */ + withUserCredential(options: UserCredentialOptions): QdmpUserClient { + return new QdmpUserClient({ + http: this.http, + appId: this.appId, + qdmpVersion: this.qdmpVersion, + auth: this.auth, + options, + }); + } } diff --git a/node/src/generated/error-codes.ts b/node/src/generated/error-codes.ts index a5a0a41..ef2f9c6 100644 --- a/node/src/generated/error-codes.ts +++ b/node/src/generated/error-codes.ts @@ -43,8 +43,8 @@ export const KNOWN_ERROR_CODES: readonly KnownErrorCode[] = [ { code: 10003, name: 'AUTH_CODE_INVALID', - message: "授权码错误", - source: "文档", + message: "授权码错误或超过 5 分钟", + source: "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)", }, { code: 10004, diff --git a/node/src/generated/schema.d.ts b/node/src/generated/schema.d.ts index 2f195c1..81ea2b0 100644 --- a/node/src/generated/schema.d.ts +++ b/node/src/generated/schema.d.ts @@ -15,7 +15,7 @@ export interface paths { put?: never; /** * 获取访问令牌 - * @description 支持两种授权模式:CLIENT_CREDENTIALS(客户端凭证)和 AUTHORIZATION_CODE(授权码,用户登录后)。 + * @description 签发两种凭证:CLIENT_CREDENTIALS 换应用凭证,代表这个开放平台应用本身,不代表任何用户,用于开发者内部调试、服务端联调、不需要用户身份的后台任务;AUTHORIZATION_CODE 换用户授权凭证,绑定 openId,代表某个真实登录用户,应用上线后调业务接口全程用它。 */ post: operations["authToken"]; delete?: never; @@ -555,7 +555,7 @@ export interface components { }; responses: never; parameters: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ AccessTokenHeader: string; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ QdmpVersionHeader: string; @@ -581,7 +581,7 @@ export interface operations { content: { "application/json": { /** - * @description 授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE + * @description 凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证) * @enum {string} */ grantType: "CLIENT_CREDENTIALS" | "AUTHORIZATION_CODE"; @@ -589,7 +589,7 @@ export interface operations { appId: string; /** @description 应用密钥 */ appSecret: string; - /** @description 授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ + /** @description 一次性授权码,由前端/App 持用户登录态调 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:"openapi"})换取,取响应的 data.code。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ code?: string; }; }; @@ -699,7 +699,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -782,7 +782,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -792,7 +792,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个 token(见 x-qdmp-token-required=true),是否传 app 级还是用户级由调用方决定,不是 SDK 自动 fallback。 */ + /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个凭证(见 x-qdmp-token-required=true),传应用凭证还是用户授权凭证由调用方决定,不是 SDK 自动 fallback。 */ 200: { headers: { [name: string]: unknown; @@ -862,7 +862,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -976,7 +976,7 @@ export interface operations { limit?: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -986,7 +986,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 可真实调通本接口(HTTP 200 正常业务响应)。 */ + /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证可真实调通本接口(HTTP 200 正常业务响应)。 */ 200: { headers: { [name: string]: unknown; @@ -1066,7 +1066,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1169,7 +1169,7 @@ export interface operations { limit?: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1261,7 +1261,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1347,7 +1347,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1449,7 +1449,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1551,7 +1551,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1654,7 +1654,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1735,7 +1735,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1823,7 +1823,7 @@ export interface operations { limit: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; diff --git a/node/src/http.ts b/node/src/http.ts index 98bca67..b35758d 100644 --- a/node/src/http.ts +++ b/node/src/http.ts @@ -12,11 +12,45 @@ import type {Dispatcher} from 'undici'; import {QdmpApiError, QdmpTransportError} from './errors.js'; +import {QDMP_ERROR_CODE} from './generated/error-codes.js'; import type {QdmpAuthScheme, QdmpHttpMethod} from './generated/route-meta.js'; +/** Supplies — and, when needed, renews — the access-token a + * credential-bound transport puts on every request it sends. Implemented by + * the 用户授权凭证 held by a QdmpUserClient; declared here because + * HttpClient is the single place that decides when a renewal is warranted. */ +export interface AccessTokenSource { + /** The access-token the next attempt should use. Implementations perform + * the proactive (pre-expiry) renewal here, so a request is never sent + * with a token that is already inside the renewal buffer. */ + currentAccessToken(): Promise; + /** Renews after an attempt was rejected as unauthorized, resolving with + * the fresh access-token. `staleAccessToken` is the token that failed, so + * an implementation can tell "my token is stale" apart from "someone else + * already renewed while my request was in flight". */ + renewAccessToken(staleAccessToken: string): Promise; +} + export interface HttpClientConfig { baseUrl: string; dispatcher?: Dispatcher; + accessTokenSource?: AccessTokenSource; +} + +/** True only for the two business errors that mean "this access-token is no + * longer usable": a real HTTP 401 carrying business code 10005 (malformed or + * revoked) or 10006 (expired). Everything else — 5xx, transport/network + * failures, any other business code, and even a 401 carrying an unrelated + * code — must be reported to the caller untouched, without renewing. */ +function isAccessTokenRejection(error: unknown): boolean { + if (!(error instanceof QdmpApiError) || error.httpStatus !== 401) { + return false; + } + const code = String(error.code); + return ( + code === String(QDMP_ERROR_CODE.ACCESS_TOKEN_INVALID) || + code === String(QDMP_ERROR_CODE.ACCESS_TOKEN_EXPIRED) + ); } /** Upper bound on how many response body bytes we'll read before giving up. @@ -173,13 +207,58 @@ function parseEnvelope(payload: unknown, httpStatus: number): T { export class HttpClient { private readonly baseUrl: string; private readonly dispatcher: Dispatcher | undefined; + private readonly accessTokenSource: AccessTokenSource | undefined; constructor(config: HttpClientConfig) { this.baseUrl = config.baseUrl; this.dispatcher = config.dispatcher; + this.accessTokenSource = config.accessTokenSource; + } + + /** Returns a sibling transport (same baseUrl/dispatcher) whose every + * request draws its access-token from `source` instead of from the + * caller-supplied `options.accessToken`, and which renews + replays once + * when the server rejects the token. Deliberately the ONE place that + * implements the renew-and-retry policy: the seventeen groups/*.ts methods + * stay free of retry logic, so "renew at most once, replay at most once" + * cannot drift between them. + * + * The transport this is called on must itself be unbound (as the root + * client's is), otherwise a renewal request would recurse into this same + * retry path. */ + withAccessTokenSource(source: AccessTokenSource): HttpClient { + return new HttpClient({ + baseUrl: this.baseUrl, + dispatcher: this.dispatcher, + accessTokenSource: source, + }); } async request(options: HttpRequestOptions): Promise { + const source = this.accessTokenSource; + if (!source) { + return this.send(options); + } + // Resolving the token BEFORE the first attempt is what implements the + // proactive (pre-expiry) renewal: the source renews in here, and only + // once it — including the caller's onRefresh hook — has settled does the + // business request go out. + const accessToken = await source.currentAccessToken(); + try { + return await this.send({...options, accessToken}); + } catch (error) { + if (!isAccessTokenRejection(error)) { + throw error; + } + const renewed = await source.renewAccessToken(accessToken); + // Exactly one replay: whatever this attempt throws (including another + // 401+10005/10006) propagates untouched, so no second renewal and no + // second retry can ever happen for one business call. + return this.send({...options, accessToken: renewed}); + } + } + + private async send(options: HttpRequestOptions): Promise { const url = new URL(options.path, this.baseUrl); appendQuery(url, options.query); diff --git a/node/src/index.ts b/node/src/index.ts index b1d057e..8383e1a 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -7,6 +7,13 @@ export { QdmpValidationError, } from './errors.js'; export type {QdmpErrorCode} from './errors.js'; +export {QdmpUserClient} from './user-client.js'; +export type {ContextBound} from './user-client.js'; +export type { + RefreshedUserToken, + UserCredentialOptions, + UserCredentialSnapshot, +} from './user-credential.js'; export {InMemoryTokenStore} from './token-store.js'; export type {StoredToken, TokenStore} from './token-store.js'; export * from './types.js'; diff --git a/node/src/redact.ts b/node/src/redact.ts new file mode 100644 index 0000000..7326331 --- /dev/null +++ b/node/src/redact.ts @@ -0,0 +1,29 @@ +import {inspect} from 'node:util'; + +/** + * Attaches a non-enumerable [util.inspect.custom] renderer to `value` so + * that `console.log(value)`/`util.inspect(value)` — an easy accidental + * debug-print path — never shows the raw accessToken/refreshToken. This is + * deliberately done via Object.defineProperty on an already-constructed, + * already-typed value rather than as an object-literal property: adding + * `[inspect.custom]: ...` directly to a `UserAccessTokenData`/ + * `RefreshTokenData` literal would trip TypeScript's excess-property check + * (none of those interfaces declares a symbol-keyed member). + * + * This must never affect JSON.stringify(value) or direct property access + * (value.accessToken): JSON.stringify only consults toJSON(), not + * util.inspect.custom, and the defined property here is non-enumerable so + * it does not add a spurious key to the object either. Persisting the real + * values is the entire point of getUserAccessToken()/refreshToken() + * existing, so that path must stay untouched. + */ +export function withRedactedInspect( + value: T, + render: () => string, +): T { + Object.defineProperty(value, inspect.custom, { + value: render, + enumerable: false, + }); + return value; +} diff --git a/node/src/token-store.ts b/node/src/token-store.ts index 0ee584c..b1fdf81 100644 --- a/node/src/token-store.ts +++ b/node/src/token-store.ts @@ -15,6 +15,15 @@ export interface StoredToken { /** Absolute Unix seconds timestamp, wire format is a string (see * shared/openapi.yaml expiresAt field notes). */ expiresAt: string; + /** The rest of the credential the CLIENT_CREDENTIALS grant returns. + * Cached alongside accessToken/expiresAt so a getAppAccessToken() call + * served from the store returns exactly as complete a credential as one + * that just performed the exchange. Optional because a store populated by + * an older SDK version (or a hand-written store) may not carry them — + * they are then reported as empty strings rather than treated as an + * error. */ + refreshToken?: string; + openId?: string; } export interface TokenStore { diff --git a/node/src/types.ts b/node/src/types.ts index faefe3e..bf857dc 100644 --- a/node/src/types.ts +++ b/node/src/types.ts @@ -18,12 +18,26 @@ export interface QdmpContext { // ---- auth ---- -export interface AppTokenData { +/** The complete 应用凭证 (grantType=CLIENT_CREDENTIALS) handed back to + * callers. The server returns all four fields for this grant too — a real, + * non-empty `refreshToken` and an always-empty `openId` (no end user is + * involved) — so callers get the full credential, not just the token + * string. */ +export interface AppAccessTokenData { accessToken: string; expiresAt: string; + /** Non-empty in practice, but deliberately not validated: the SDK renews + * the 应用凭证 by re-running the CLIENT_CREDENTIALS grant, so this value is + * only passed through for callers who want to renew it themselves. */ + refreshToken: string; + /** Always an empty string for CLIENT_CREDENTIALS — kept so the shape + * matches {@link UserAccessTokenData}. */ + openId: string; } -export interface SessionData { +/** The complete 用户授权凭证 (grantType=AUTHORIZATION_CODE). Bound to a real + * end user, so `refreshToken`/`openId` are both required to be non-empty. */ +export interface UserAccessTokenData { accessToken: string; refreshToken: string; expiresAt: string; @@ -32,7 +46,7 @@ export interface SessionData { export interface RefreshTokenData { accessToken: string; - expiresAt?: string; + expiresAt: string; } // ---- user ---- diff --git a/node/src/user-client.ts b/node/src/user-client.ts new file mode 100644 index 0000000..8823237 --- /dev/null +++ b/node/src/user-client.ts @@ -0,0 +1,119 @@ +/** + * The client returned by `qdmp.withUserCredential(...)`: the same seventeen + * business operations as the root QdmpClient, except every one of them is + * already bound to one 用户授权凭证 and therefore takes only its own + * parameters — no QdmpContext, no access-token threading by the caller. + * + * The groups themselves are reused untouched. They receive a QdmpContext + * whose accessToken is a live view of the credential (so the existing local + * header-safety validation still runs on every call) and a transport bound + * to that same credential (so the token actually sent is the one the + * credential resolves at request time, after any renewal). + */ +import type {AuthModule} from './auth.js'; +import type {GroupDeps} from './groups/base.js'; +import {GenaiGroup} from './groups/genai.js'; +import {IslandGroup} from './groups/island.js'; +import {MarkGroup} from './groups/mark.js'; +import {SpuGroup} from './groups/spu.js'; +import {TagGroup} from './groups/tag.js'; +import {UserGroup} from './groups/user.js'; +import {WishSpuGroup} from './groups/wishspu.js'; +import type {HttpClient} from './http.js'; +import type {QdmpContext} from './types.js'; +import { + UserCredential, + type UserCredentialOptions, + type UserCredentialSnapshot, +} from './user-credential.js'; + +/** A business group with the leading QdmpContext parameter stripped from + * every method — the shape those methods take on a QdmpUserClient. */ +export type ContextBound = { + [K in keyof T]: T[K] extends (ctx: QdmpContext, ...args: infer A) => infer R + ? (...args: A) => R + : never; +}; + +/** Wraps each of `group`'s methods so callers no longer pass `ctx`. Driven + * off the prototype rather than a hand-written list, so a group gaining a + * method never silently misses it here. */ +function bindContext( + group: T, + ctx: QdmpContext, +): ContextBound { + const bound: Record = {}; + const prototype = Object.getPrototypeOf(group) as object; + for (const key of Object.getOwnPropertyNames(prototype)) { + if (key === 'constructor') { + continue; + } + const method = (group as Record)[key]; + if (typeof method !== 'function') { + continue; + } + const callable = method as (...args: unknown[]) => unknown; + bound[key] = (...args: unknown[]): unknown => + callable.apply(group, [ctx, ...args]); + } + return bound as ContextBound; +} + +export interface QdmpUserClientDeps { + /** The root client's unbound transport; a credential-bound sibling is + * derived from it here. */ + http: HttpClient; + appId: string; + qdmpVersion: string; + /** Used only to perform POST /auth/v1/refresh when the credential renews. */ + auth: AuthModule; + options: UserCredentialOptions; +} + +export class QdmpUserClient { + readonly user: ContextBound; + readonly island: ContextBound; + readonly spu: ContextBound; + readonly tag: ContextBound; + readonly mark: ContextBound; + readonly wishspu: ContextBound; + readonly genai: ContextBound; + + private readonly state: UserCredential; + + constructor(deps: QdmpUserClientDeps) { + // Constructing the credential first means an invalid accessToken / + // refreshToken is reported before any group even exists, and long before + // any request could go out. + const state = new UserCredential(deps.auth, deps.options); + this.state = state; + + const groupDeps: GroupDeps = { + http: deps.http.withAccessTokenSource(state), + appId: deps.appId, + qdmpVersion: deps.qdmpVersion, + }; + // A live view rather than a snapshot: the group reads it at call time, + // so requireAccessToken() validates whatever token is current, including + // one obtained by a renewal. + const ctx: QdmpContext = { + get accessToken(): string { + return state.snapshot().accessToken; + }, + }; + + this.user = bindContext(new UserGroup(groupDeps), ctx); + this.island = bindContext(new IslandGroup(groupDeps), ctx); + this.spu = bindContext(new SpuGroup(groupDeps), ctx); + this.tag = bindContext(new TagGroup(groupDeps), ctx); + this.mark = bindContext(new MarkGroup(groupDeps), ctx); + this.wishspu = bindContext(new WishSpuGroup(groupDeps), ctx); + this.genai = bindContext(new GenaiGroup(groupDeps), ctx); + } + + /** The credential as it stands right now — read it before the process + * exits to persist the latest token. Each read returns a fresh copy. */ + get credential(): UserCredentialSnapshot { + return this.state.snapshot(); + } +} diff --git a/node/src/user-credential.ts b/node/src/user-credential.ts new file mode 100644 index 0000000..4d1bc02 --- /dev/null +++ b/node/src/user-credential.ts @@ -0,0 +1,192 @@ +/** + * The 用户授权凭证 (AUTHORIZATION_CODE grant) held by a QdmpUserClient, plus + * its renewal policy. + * + * Two things renew it, and nothing else does: + * - proactively, when `expiresAt` is known and within + * USER_TOKEN_RENEWAL_BUFFER_SECONDS of now (checked before every business + * request, in currentAccessToken()); + * - reactively, when a business request came back HTTP 401 with business + * code 10005/10006 (decided by HttpClient, which then calls + * renewAccessToken()). + * + * Renewals are single-flight, so N concurrent business calls that all see a + * stale token trigger exactly one POST /auth/v1/refresh and all end up using + * the one access-token it returned. + */ +import type {AuthModule} from './auth.js'; +import {QdmpValidationError} from './errors.js'; +import type {AccessTokenSource} from './http.js'; +import {withRedactedInspect} from './redact.js'; + +/** Renew this many seconds before the credential's real expiresAt — same + * buffer the 应用凭证 uses. */ +const USER_TOKEN_RENEWAL_BUFFER_SECONDS = 300; + +/** What a renewal produced. POST /auth/v1/refresh returns only these two + * fields — the refreshToken is not reissued — so this is exactly what the + * caller's onRefresh hook receives. */ +export interface RefreshedUserToken { + accessToken: string; + expiresAt: string; +} + +export interface UserCredentialOptions { + /** Non-empty; rejected locally if it carries a character that cannot be + * sent as an HTTP header value. */ + accessToken: string; + /** Non-empty. Without it the credential could not be renewed at all, which + * is the entire point of withUserCredential(). */ + refreshToken: string; + /** Absolute Unix seconds, as a string. Omit it when unknown: the + * credential then skips proactive renewal and relies solely on the + * 401 + 10005/10006 signal. */ + expiresAt?: string; + /** Called after the SDK's own state has been updated with a renewed + * token, so the caller can persist it. Awaited — the business request that + * triggered the renewal is not sent until it settles — and a rejection + * fails that business call. */ + onRefresh?: (token: RefreshedUserToken) => void | Promise; +} + +/** A read-only copy of the credential's current state. Mutating it has no + * effect on the client it came from. */ +export interface UserCredentialSnapshot { + accessToken: string; + refreshToken: string; + expiresAt?: string; +} + +/** Rejects a token that cannot be put in an HTTP header verbatim. Never + * echoes the offending value into the error message. */ +function assertHeaderSafe(value: string, field: string): void { + for (let i = 0; i < value.length; i++) { + const charCode = value.charCodeAt(i); + if (charCode <= 0x1f || charCode === 0x7f) { + throw new QdmpValidationError( + `withUserCredential: "${field}" contains a character that cannot be safely sent as an HTTP header value`, + ); + } + } +} + +/** Parses the wire format (absolute Unix seconds as a decimal string). + * Anything else — including undefined — means "expiry unknown", which + * disables proactive renewal rather than failing the call. */ +function parseExpiresAtSeconds( + expiresAt: string | undefined, +): number | undefined { + if (typeof expiresAt !== 'string' || !/^\d+$/.test(expiresAt)) { + return undefined; + } + const seconds = Number(expiresAt); + return Number.isSafeInteger(seconds) ? seconds : undefined; +} + +export class UserCredential implements AccessTokenSource { + private readonly auth: AuthModule; + private readonly refreshToken: string; + private readonly onRefresh: + ((token: RefreshedUserToken) => void | Promise) | undefined; + private accessToken: string; + private expiresAt: string | undefined; + private inFlight: Promise | undefined; + + constructor(auth: AuthModule, options: UserCredentialOptions) { + if ( + typeof options?.accessToken !== 'string' || + options.accessToken.length === 0 + ) { + throw new QdmpValidationError( + 'withUserCredential: "accessToken" is required and must be a non-empty string', + ); + } + assertHeaderSafe(options.accessToken, 'accessToken'); + if ( + typeof options.refreshToken !== 'string' || + options.refreshToken.length === 0 + ) { + throw new QdmpValidationError( + 'withUserCredential: "refreshToken" is required and must be a non-empty string — without it the credential cannot be renewed', + ); + } + this.auth = auth; + this.accessToken = options.accessToken; + this.refreshToken = options.refreshToken; + this.expiresAt = options.expiresAt; + this.onRefresh = options.onRefresh; + } + + /** A fresh object every time, so a caller that mutates what it got back + * cannot corrupt the credential this client keeps using. */ + snapshot(): UserCredentialSnapshot { + const snapshot: UserCredentialSnapshot = { + accessToken: this.accessToken, + refreshToken: this.refreshToken, + expiresAt: this.expiresAt, + }; + return withRedactedInspect( + snapshot, + () => + `UserCredentialSnapshot { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(snapshot.expiresAt)} }`, + ); + } + + /** Deliberately NOT `async`: the check-and-set of `this.inFlight` inside + * renew() must run in the caller's own synchronous turn, so that business + * calls started in the same tick all join one renewal instead of each + * starting their own. */ + currentAccessToken(): Promise { + if (this.needsProactiveRenewal()) { + return this.renew(); + } + return Promise.resolve(this.accessToken); + } + + renewAccessToken(staleAccessToken: string): Promise { + // A concurrent call may have already renewed while this request was in + // flight — its result is by definition newer than the token that just + // got rejected, so use it rather than burning a second renewal. + if (this.accessToken !== staleAccessToken) { + return Promise.resolve(this.accessToken); + } + return this.renew(); + } + + private needsProactiveRenewal(): boolean { + const expiresAtSeconds = parseExpiresAtSeconds(this.expiresAt); + if (expiresAtSeconds === undefined) { + return false; + } + const nowSeconds = Math.floor(Date.now() / 1000); + return expiresAtSeconds - nowSeconds <= USER_TOKEN_RENEWAL_BUFFER_SECONDS; + } + + private renew(): Promise { + if (!this.inFlight) { + this.inFlight = this.performRenewal().finally(() => { + this.inFlight = undefined; + }); + } + return this.inFlight; + } + + private async performRenewal(): Promise { + const refreshed = await this.auth.refreshToken(this.refreshToken); + // The server does not reissue a refreshToken here, so the original one + // stays in use for every later renewal. + this.accessToken = refreshed.accessToken; + this.expiresAt = refreshed.expiresAt; + if (this.onRefresh) { + // Awaited on purpose, and after the state above was already updated: + // if persisting fails, the business call fails with that error, but a + // token the SDK has already obtained is not thrown away — the caller + // can still read it off `userClient.credential`. + await this.onRefresh({ + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + }); + } + return this.accessToken; + } +} diff --git a/node/test/auth-expired-fresh-token.test.ts b/node/test/auth-expired-fresh-token.test.ts index a11274f..e56abfe 100644 --- a/node/test/auth-expired-fresh-token.test.ts +++ b/node/test/auth-expired-fresh-token.test.ts @@ -27,7 +27,7 @@ import { expectFailure, } from './helpers/mock-http.js'; -void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过期的时间戳("1",1970年)时应 reject,且不得把这个已过期 token 当作新鲜缓存写入(下一次调用仍会真实重新换取)', async () => { +void test('auth.getAppAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过期的时间戳("1",1970年)时应 reject,且不得把这个已过期 token 当作新鲜缓存写入(下一次调用仍会真实重新换取)', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) @@ -42,7 +42,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); // 验证这个"已过期即到达"的坏值没有被当作新鲜 token 缓存下来:只注册这一个 // 新拦截器,若上一次的失败结果被误当成功缓存,这里就不会真的再发一次 HTTP @@ -55,9 +55,9 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 }), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'app-token-good', '前一次已过期 token 被拒绝之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用坏缓存', ); @@ -68,11 +68,11 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 ); }); -// 回归测试(codex 收敛检查轮发现):code2Session/refreshToken 这两个"一次性、 +// 回归测试(codex 收敛检查轮发现):getUserAccessToken/refreshToken 这两个"一次性、 // 不缓存"的路径同样只校验 expiresAt 是否语法合法(非负整数字符串),没有校验 // 它是否早已过期——一次性会话/token 虽然不走本 SDK 的缓存,但如果早已过期就 // 返回给调用方,调用方可能会把这个"看起来成功"的死会话持久化下来。 -void test('auth.code2Session: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { +void test('auth.getUserAccessToken: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -90,7 +90,7 @@ void test('auth.code2Session: expiresAt 是早已过期的时间戳("1")时 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.code2Session('wx-login-code-abc')); + await expectFailure(() => client.auth.getUserAccessToken('auth-code-abc')); }); void test('auth.refreshToken: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { diff --git a/node/test/auth-malformed-success.test.ts b/node/test/auth-malformed-success.test.ts index 8ea4097..404f847 100644 --- a/node/test/auth-malformed-success.test.ts +++ b/node/test/auth-malformed-success.test.ts @@ -5,7 +5,7 @@ * - resolve 成 undefined / 空字符串; * - 把这个坏值写进 tokenStore,污染后续调用的缓存。 * - * 覆盖 getAccessToken()(CLIENT_CREDENTIALS)与 refreshToken()。 + * 覆盖 getAppAccessToken()(CLIENT_CREDENTIALS)与 refreshToken()。 * * 现状(本测试写下时): * - parseEnvelope() 只用 `String(code) === '0'` 判定成功,不检查 data 内容 @@ -33,7 +33,7 @@ function tokenSuccessData(overrides: Record = {}) { }; } -void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段时应 reject,且不得把坏值写进 tokenStore(下一次调用仍会真实重新换取)', async () => { +void test('auth.getAppAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段时应 reject,且不得把坏值写进 tokenStore(下一次调用仍会真实重新换取)', async () => { const {mockAgent, pool} = createMockAgent(); // 故意不用 businessSuccess() 帮助函数:这里就是要构造"code=0 但没有 data 字段" // 这种畸形成功响应。 @@ -47,7 +47,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); // 验证失败之后没有任何坏值被当作"已缓存的新鲜 token"留存下来: // 只注册这一个新拦截器,若上一次的失败结果被误当成功缓存,这里就不会真的 @@ -59,9 +59,9 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 businessSuccess(tokenSuccessData({accessToken: 'app-token-good'})), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'app-token-good', '前一次畸形响应失败之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用坏缓存', ); @@ -72,7 +72,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 ); }); -void test('auth.getAccessToken: data 存在但 data.accessToken 为空字符串时应 reject,不得把空字符串当作合法 token resolve 给调用方', async () => { +void test('auth.getAppAccessToken: data 存在但 data.accessToken 为空字符串时应 reject,不得把空字符串当作合法 token resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -88,10 +88,10 @@ void test('auth.getAccessToken: data 存在但 data.accessToken 为空字符串 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为空字符串时应 reject(Number("")===0 曾让这个畸形值被当成"已过期但合法"的数字通过校验)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为空字符串时应 reject(Number("")===0 曾让这个畸形值被当成"已过期但合法"的数字通过校验)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -107,10 +107,10 @@ void test('auth.getAccessToken: data.expiresAt 为空字符串时应 reject(Nu dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为纯空白字符串时应 reject(Number(" ")===0 曾让它被当成合法数字通过校验)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为纯空白字符串时应 reject(Number(" ")===0 曾让它被当成合法数字通过校验)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -126,10 +126,10 @@ void test('auth.getAccessToken: data.expiresAt 为纯空白字符串时应 rejec dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为科学计数法(如 "1e100")时应 reject(Go/Java 的严格整数解析都会拒绝这类格式,Node 不应更宽松)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为科学计数法(如 "1e100")时应 reject(Go/Java 的严格整数解析都会拒绝这类格式,Node 不应更宽松)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -145,7 +145,7 @@ void test('auth.getAccessToken: data.expiresAt 为科学计数法(如 "1e100" dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); void test('auth.refreshToken: data.accessToken 为空字符串时应 reject,不得把空字符串 resolve 给调用方', async () => { diff --git a/node/test/auth-result-inspect-redaction.test.ts b/node/test/auth-result-inspect-redaction.test.ts index a193d37..1be44b8 100644 --- a/node/test/auth-result-inspect-redaction.test.ts +++ b/node/test/auth-result-inspect-redaction.test.ts @@ -1,20 +1,20 @@ /** - * 回归测试:code2Session()/refreshToken() 返回值的调试打印脱敏。 + * 回归测试:getUserAccessToken()/refreshToken() 返回值的调试打印脱敏。 * - * 背景(Fable5 设计一致性审查发现的真实安全问题):code2Session()/ + * 背景(Fable5 设计一致性审查发现的真实安全问题):getUserAccessToken()/ * refreshToken() 返回的普通 object literal(类型见 src/types.ts 的 - * SessionData/RefreshTokenData)一旦被 console.log(session)/util.inspect(session) + * UserAccessTokenData/RefreshTokenData)一旦被 console.log(userCredential)/util.inspect(userCredential) * 这类"意外调试打印"路径打印,就会把 accessToken/refreshToken 明文暴露出去。 * - * 修复(src/auth.ts 的 withRedactedInspect())给返回对象挂了一个不可枚举的 + * 修复(src/redact.ts 的 withRedactedInspect())给返回对象挂了一个不可枚举的 * [util.inspect.custom] 方法,只影响 util.inspect()/console.log() 这条路径。 * * 本测试同时覆盖两个方向: - * 1. util.inspect(session) 拿到的字符串表示不能包含真实的 accessToken/refreshToken。 - * 2. 反向验证:JSON.stringify(session) 和直接属性访问(session.accessToken / - * session.refreshToken)必须仍然是原始明文值——因为 code2Session()/ + * 1. util.inspect(userCredential) 拿到的字符串表示不能包含真实的 accessToken/refreshToken。 + * 2. 反向验证:JSON.stringify(userCredential) 和直接属性访问(userCredential.accessToken / + * userCredential.refreshToken)必须仍然是原始明文值——因为 getUserAccessToken()/ * refreshToken() 存在的整个目的就是让调用方把这些真实值持久化到自己的 - * 数据库/session store,如果连 JSON.stringify 都被脱敏了 SDK 就没法用了。 + * 数据库/userCredential store,如果连 JSON.stringify 都被脱敏了 SDK 就没法用了。 */ import assert from 'node:assert/strict'; import {test} from 'node:test'; @@ -36,7 +36,7 @@ function makeClient( }); } -void test('auth.code2Session: util.inspect()/console.log() 路径不得暴露 accessToken/refreshToken 明文', async () => { +void test('auth.getUserAccessToken: util.inspect()/console.log() 路径不得暴露 accessToken/refreshToken 明文', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -49,35 +49,35 @@ void test('auth.code2Session: util.inspect()/console.log() 路径不得暴露 ac ); const client = makeClient(mockAgent); - const session = await client.auth.code2Session('some-auth-code'); + const userCredential = await client.auth.getUserAccessToken('some-auth-code'); - const rendered = inspect(session); + const rendered = inspect(userCredential); assert.ok( !rendered.includes(SENTINEL_ACCESS_TOKEN), - `util.inspect(session) leaked the raw accessToken: ${rendered}`, + `util.inspect(userCredential) leaked the raw accessToken: ${rendered}`, ); assert.ok( !rendered.includes(SENTINEL_REFRESH_TOKEN), - `util.inspect(session) leaked the raw refreshToken: ${rendered}`, + `util.inspect(userCredential) leaked the raw refreshToken: ${rendered}`, ); assert.ok( rendered.includes('[REDACTED]'), - `util.inspect(session) should contain the [REDACTED] placeholder: ${rendered}`, + `util.inspect(userCredential) should contain the [REDACTED] placeholder: ${rendered}`, ); // Reverse-direction guard: JSON.stringify (how the caller persists the - // session) and direct property access must both stay real plaintext. - const json = JSON.stringify(session); + // userCredential) and direct property access must both stay real plaintext. + const json = JSON.stringify(userCredential); assert.ok( json.includes(SENTINEL_ACCESS_TOKEN), - `JSON.stringify(session) must still contain the real accessToken for the caller to persist: ${json}`, + `JSON.stringify(userCredential) must still contain the real accessToken for the caller to persist: ${json}`, ); assert.ok( json.includes(SENTINEL_REFRESH_TOKEN), - `JSON.stringify(session) must still contain the real refreshToken for the caller to persist: ${json}`, + `JSON.stringify(userCredential) must still contain the real refreshToken for the caller to persist: ${json}`, ); - assert.equal(session.accessToken, SENTINEL_ACCESS_TOKEN); - assert.equal(session.refreshToken, SENTINEL_REFRESH_TOKEN); + assert.equal(userCredential.accessToken, SENTINEL_ACCESS_TOKEN); + assert.equal(userCredential.refreshToken, SENTINEL_REFRESH_TOKEN); }); void test('auth.refreshToken: util.inspect()/console.log() 路径不得暴露 accessToken 明文', async () => { @@ -113,9 +113,9 @@ void test('auth.refreshToken: util.inspect()/console.log() 路径不得暴露 ac // 回归测试(codex 收敛检查轮发现):openId 只校验非空字符串,服务端返回一个 // 嵌入 \r\n 的 openId 时,withRedactedInspect() 的渲染函数如果直接把它拼进 -// 模板字符串,util.inspect(session) 的结果就会出现原始换行——伪造出看似 +// 模板字符串,util.inspect(userCredential) 的结果就会出现原始换行——伪造出看似 // 独立的新日志行,和 message/code/requestId 已经修过的日志注入是同一类问题。 -void test('auth.code2Session: openId 里嵌入的 \\r\\n 不得原样出现在 util.inspect() 的调试渲染中(防日志注入)', async () => { +void test('auth.getUserAccessToken: openId 里嵌入的 \\r\\n 不得原样出现在 util.inspect() 的调试渲染中(防日志注入)', async () => { const injectedOpenId = 'open-id-1\r\nFAKE injected log line'; const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( @@ -129,14 +129,14 @@ void test('auth.code2Session: openId 里嵌入的 \\r\\n 不得原样出现在 u ); const client = makeClient(mockAgent); - const session = await client.auth.code2Session('some-auth-code'); + const userCredential = await client.auth.getUserAccessToken('some-auth-code'); - const rendered = inspect(session); + const rendered = inspect(userCredential); assert.ok( !rendered.includes('\r'), - `util.inspect(session) 不应包含 openId 中原始的 \\r 字符,实际: ${JSON.stringify(rendered)}`, + `util.inspect(userCredential) 不应包含 openId 中原始的 \\r 字符,实际: ${JSON.stringify(rendered)}`, ); // 反向验证:JSON.stringify 仍然携带真实的(哪怕含控制字符的)openId,因为 - // 这是调用方持久化 session 的正常路径,不应该被这个调试渲染修复影响到。 - assert.equal(session.openId, injectedOpenId); + // 这是调用方持久化 userCredential 的正常路径,不应该被这个调试渲染修复影响到。 + assert.equal(userCredential.openId, injectedOpenId); }); diff --git a/node/test/auth-tokenstore-write-failure.test.ts b/node/test/auth-tokenstore-write-failure.test.ts index 9d85db5..16d4f0b 100644 --- a/node/test/auth-tokenstore-write-failure.test.ts +++ b/node/test/auth-tokenstore-write-failure.test.ts @@ -54,7 +54,7 @@ class RejectOnceTokenStore implements TokenStore { } } -void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该次调用必须 reject,且不得把这个未持久化的值当作本地新鲜缓存复用', async () => { +void test('auth.getAppAccessToken: tokenStore.set() 第一次写入失败时,该次调用必须 reject,且不得把这个未持久化的值当作本地新鲜缓存复用', async () => { const {mockAgent, pool} = createMockAgent(); const tokenStore = new RejectOnceTokenStore(/* rejectFirstSet= */ true); @@ -73,7 +73,7 @@ void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该 tokenStore, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); assert.equal( tokenStore.setCallCount, 1, @@ -91,9 +91,9 @@ void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该 }), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'token-from-good-write', '第一次写入失败之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用从未成功持久化的本地缓存', ); diff --git a/node/test/auth-session-fields.test.ts b/node/test/auth-user-access-token-fields.test.ts similarity index 51% rename from node/test/auth-session-fields.test.ts rename to node/test/auth-user-access-token-fields.test.ts index 08726e7..edd1b33 100644 --- a/node/test/auth-session-fields.test.ts +++ b/node/test/auth-user-access-token-fields.test.ts @@ -1,18 +1,18 @@ /** - * auth.code2Session() 字段完整性校验测试。 + * auth.getUserAccessToken() 字段完整性校验测试。 * - * 需求:code2Session() 除了要求 accessToken/expiresAt 合法(已由 + * 需求:getUserAccessToken() 除了要求 accessToken/expiresAt 合法(已由 * assertWellFormedTokenResponse() 覆盖,见 auth-malformed-success.test.ts), * 还必须校验业务层返回的 refreshToken/openId 非空 —— 这两个字段是 - * SessionData 的一部分(见 src/types.ts),调用方(宿主后端)需要拿 + * UserAccessTokenData 的一部分(见 src/types.ts),调用方(宿主后端)需要拿 * refreshToken 去后续换取新 token、拿 openId 去关联本地用户,一旦 * 静默 resolve 出 undefined/空字符串,后续业务逻辑会在错误的地方失败 * (比如把空字符串当 openId 存进数据库)。 * - * 现状(本测试写下时):AuthModule.code2Session()(src/auth.ts 约152-172行) - * 只调用 assertWellFormedTokenResponse(session, ...) 校验 accessToken/expiresAt, - * 完全不检查 session.refreshToken / session.openId 是否存在或非空。 - * 因此下面的测试预期是失败的(红色状态):当前实现会把畸形 session 直接 + * 现状(本测试写下时):AuthModule.getUserAccessToken()(src/auth.ts 约152-172行) + * 只调用 assertWellFormedTokenResponse(userCredential, ...) 校验 accessToken/expiresAt, + * 完全不检查 userCredential.refreshToken / userCredential.openId 是否存在或非空。 + * 因此下面的测试预期是失败的(红色状态):当前实现会把畸形 userCredential 直接 * resolve 给调用方,而不是 reject。 */ import {test} from 'node:test'; @@ -24,12 +24,12 @@ import { expectFailure, } from './helpers/mock-http.js'; -function sessionData( +function userCredentialData( overrides: Record = {}, ): Record { return { - accessToken: 'session-access-abc', - refreshToken: 'session-refresh-abc', + accessToken: 'userCredential-access-abc', + refreshToken: 'userCredential-refresh-abc', expiresAt: String(Math.floor(Date.now() / 1000) + 7200), openId: 'open-id-abc', ...overrides, @@ -46,45 +46,45 @@ function makeClient( }); } -void test('auth.code2Session: refreshToken 字段完全缺失时应 reject', async () => { +void test('auth.getUserAccessToken: refreshToken 字段完全缺失时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); - // sessionData({refreshToken: undefined}) 展开后 refreshToken 的值是 + // userCredentialData({refreshToken: undefined}) 展开后 refreshToken 的值是 // undefined,JSON.stringify(undici MockAgent 序列化 reply body 时会用到) // 会直接丢弃 undefined 值的 key,等价于"响应里完全没有这个字段"。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({refreshToken: undefined}))); + .reply(200, businessSuccess(userCredentialData({refreshToken: undefined}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: refreshToken 为空字符串时应 reject,不得把空字符串当作合法 refreshToken resolve 给调用方', async () => { +void test('auth.getUserAccessToken: refreshToken 为空字符串时应 reject,不得把空字符串当作合法 refreshToken resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({refreshToken: ''}))); + .reply(200, businessSuccess(userCredentialData({refreshToken: ''}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: openId 字段完全缺失时应 reject', async () => { +void test('auth.getUserAccessToken: openId 字段完全缺失时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({openId: undefined}))); + .reply(200, businessSuccess(userCredentialData({openId: undefined}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: openId 为空字符串时应 reject,不得把空字符串当作合法 openId resolve 给调用方', async () => { +void test('auth.getUserAccessToken: openId 为空字符串时应 reject,不得把空字符串当作合法 openId resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({openId: ''}))); + .reply(200, businessSuccess(userCredentialData({openId: ''}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); diff --git a/node/test/auth.test.ts b/node/test/auth.test.ts index 50d1884..3159002 100644 --- a/node/test/auth.test.ts +++ b/node/test/auth.test.ts @@ -1,5 +1,5 @@ /** - * auth 模块测试:app 级 token 缓存 + 单飞刷新 + 300 秒缓冲提前刷新 + code2Session + + * auth 模块测试:app 级 token 缓存 + 单飞刷新 + 300 秒缓冲提前刷新 + getUserAccessToken + * refreshToken 一次性换取。均依据 shared/openapi.yaml 的 authToken/authRefresh * operation 的 token 生命周期模型。 * @@ -8,7 +8,7 @@ * - app 级 token 并发单飞(只触发一次真实 HTTP 换取) * - 300 秒缓冲提前刷新(不是等到真正过期才刷新) * - HTTP 200 + code !== '0' 判定为失败(不能只信 HTTP 状态码) - * - code2Session(AUTHORIZATION_CODE)不进入 app 级缓存 + * - getUserAccessToken(AUTHORIZATION_CODE)不进入 app 级缓存 * - refreshToken 一次性刷新,HTTP 200 + code:10008(refreshToken 超期)判定为失败 * - 错误信息不泄露 appSecret */ @@ -108,7 +108,7 @@ function tokenSuccessData(overrides: Record = {}) { }; } -void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app 级 token', async () => { +void test('auth.getAppAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app 级 token', async () => { const {mockAgent, pool} = createMockAgent(); let capturedBody: unknown; pool @@ -128,9 +128,9 @@ void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app dispatcher: mockAgent, }); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); - assert.equal(token, 'app-token-abc'); + assert.equal(token.accessToken, 'app-token-abc'); assert.deepEqual(capturedBody, { grantType: 'CLIENT_CREDENTIALS', appId: 'app-id-1', @@ -138,7 +138,7 @@ void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app }); }); -void test('auth.getAccessToken: 并发调用只触发一次真实换取请求(单飞去重)', async () => { +void test('auth.getAppAccessToken: 并发调用只触发一次真实换取请求(单飞去重)', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一次拦截器:若实现没有做单飞去重,第二/三次并发请求会找不到匹配的 // 拦截器而报错(因为 disableNetConnect),从而暴露问题。 @@ -153,14 +153,14 @@ void test('auth.getAccessToken: 并发调用只触发一次真实换取请求( }); const [t1, t2, t3] = await Promise.all([ - client.auth.getAccessToken(), - client.auth.getAccessToken(), - client.auth.getAccessToken(), + client.auth.getAppAccessToken(), + client.auth.getAppAccessToken(), + client.auth.getAppAccessToken(), ]); - assert.equal(t1, 'app-token-abc'); - assert.equal(t2, 'app-token-abc'); - assert.equal(t3, 'app-token-abc'); + assert.equal(t1.accessToken, 'app-token-abc'); + assert.equal(t2.accessToken, 'app-token-abc'); + assert.equal(t3.accessToken, 'app-token-abc'); assert.equal( mockAgent.pendingInterceptors().length, 0, @@ -168,11 +168,18 @@ void test('auth.getAccessToken: 并发调用只触发一次真实换取请求( ); }); -void test('auth.getAccessToken: 缓存命中时不重复发起 HTTP 请求', async () => { +void test('auth.getAppAccessToken: 缓存命中时不重复发起 HTTP 请求,且缓存命中路径返回的应用凭证与新换取路径同样完整', async () => { const {mockAgent, pool} = createMockAgent(); + // 按实测事实构造:CLIENT_CREDENTIALS 响应同样带 refreshToken(真实非空值) + // 和 openId(空字符串)。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(tokenSuccessData())); + .reply( + 200, + businessSuccess( + tokenSuccessData({refreshToken: 'app-refresh-abc', openId: ''}), + ), + ); const client = new QdmpClient({ appId: 'app-id-1', @@ -180,16 +187,27 @@ void test('auth.getAccessToken: 缓存命中时不重复发起 HTTP 请求', asy dispatcher: mockAgent, }); - const first = await client.auth.getAccessToken(); + const first = await client.auth.getAppAccessToken(); // 没有再注册第二个拦截器:如果实现没有缓存、又发起了第二次 HTTP 请求, // disableNetConnect 会让该请求报错,下面这行就会抛出。 - const second = await client.auth.getAccessToken(); + const second = await client.auth.getAppAccessToken(); - assert.equal(first, 'app-token-abc'); - assert.equal(second, 'app-token-abc'); + assert.equal(first.accessToken, 'app-token-abc'); + assert.equal(second.accessToken, 'app-token-abc'); + assert.equal(first.refreshToken, 'app-refresh-abc'); + assert.equal( + first.openId, + '', + '应用凭证的 openId 实测就是空字符串,不应被当作缺失字段报错', + ); + assert.deepEqual( + {...second}, + {...first}, + '缓存命中那一次返回的凭证必须与刚换取那一次逐字段相同,不能少 refreshToken/openId', + ); }); -void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即主动刷新(不是等到真正过期)', async t => { +void test('auth.getAppAccessToken: 距真正过期还有 300 秒缓冲区间内即主动刷新(不是等到真正过期)', async t => { t.mock.timers.enable({apis: ['Date']}); const nowSeconds = Math.floor(Date.now() / 1000); @@ -210,8 +228,8 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 dispatcher: mockAgent, }); - const first = await client.auth.getAccessToken(); - assert.equal(first, 'token-1'); + const first = await client.auth.getAppAccessToken(); + assert.equal(first.accessToken, 'token-1'); // 时间前进 150 秒:距真正过期(400 秒后)还剩 250 秒,落在 300 秒缓冲区间内, // 理应主动换新,而不是复用这个"技术上还没过期但快过期"的旧 token。 @@ -227,10 +245,10 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 ), ); - const second = await client.auth.getAccessToken(); + const second = await client.auth.getAppAccessToken(); assert.equal( - second, + second.accessToken, 'token-2', '进入 300 秒缓冲期后应当换新 token,而不是继续返回 token-1', ); @@ -241,7 +259,7 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 ); }); -void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧随其后的调用命中进程内缓存而不重复换取', async () => { +void test('auth.getAppAccessToken: 异步 TokenStore 存在"回读延迟"时,紧随其后的调用命中进程内缓存而不重复换取', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一个拦截器:若第二次调用没有命中本地缓存、退化为重新调用 // tokenStore.get()(此刻仍返回旧值/undefined)再触发 refreshAppToken(), @@ -258,8 +276,8 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 tokenStore, }); - const first = await client.auth.getAccessToken(); - assert.equal(first, 'token-1'); + const first = await client.auth.getAppAccessToken(); + assert.equal(first.accessToken, 'token-1'); assert.equal( tokenStore.getCalls, 1, @@ -268,8 +286,8 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 // 紧接着再次调用:此时 tokenStore 的 visibleValue 仍是回读延迟窗口内的旧值 // (setTimeout(50ms) 还没触发),只有本地 cachedToken 能证明拿到了新 token。 - const second = await client.auth.getAccessToken(); - assert.equal(second, 'token-1'); + const second = await client.auth.getAppAccessToken(); + assert.equal(second.accessToken, 'token-1'); assert.equal( tokenStore.getCalls, 1, @@ -282,7 +300,7 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 ); }); -void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调用者的单飞刷新已完成之后才 resolve(且值仍是刷新前的旧快照)时,不应触发第二次冗余换取', async () => { +void test('auth.getAppAccessToken: 并发调用者的 tokenStore.get() 在另一调用者的单飞刷新已完成之后才 resolve(且值仍是刷新前的旧快照)时,不应触发第二次冗余换取', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一个拦截器:若实现在这种交错时序下仍触发第二次 CLIENT_CREDENTIALS // 换取,disableNetConnect 会让第二次请求找不到拦截器而报错,从而暴露问题。 @@ -301,14 +319,14 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 // 两个调用"同时"发起:两者都在各自看到 this.cachedToken 为空后, // 各自调用了一次 tokenStore.get()(此刻都拿到了「无 token」的快照,都还未 // resolve)。 - const callA = client.auth.getAccessToken(); - const callB = client.auth.getAccessToken(); + const callA = client.auth.getAppAccessToken(); + const callB = client.auth.getAppAccessToken(); // 放行 A 的 get():A 发现无缓存,进入单飞刷新,真实发起(唯一注册的)HTTP // 换取请求,成功后设置 this.cachedToken 并清空 inFlight。 tokenStore.releaseNextGet(); const resultA = await callA; - assert.equal(resultA, 'token-1'); + assert.equal(resultA.accessToken, 'token-1'); // 放行 B 的 get():resolve 的是 B 发起 get() 那一刻捕获的快照(当时仍是 // 「无 token」),复现"回读结果滞后于另一并发调用者已完成的刷新"这一时序。 @@ -318,7 +336,7 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 const resultB = await callB; assert.equal( - resultB, + resultB.accessToken, 'token-1', 'B 应当复用 A 已经写入的进程内缓存 token,而不是触发第二次冗余换取', ); @@ -329,7 +347,7 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 ); }); -void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败,不能只信 HTTP 状态码', async () => { +void test('auth.getAppAccessToken: HTTP 200 但业务 code !== "0" 判定为失败,不能只信 HTTP 状态码', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) @@ -341,7 +359,7 @@ void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, `应当抛出 QdmpApiError,实际抛出: ${err}`, @@ -351,7 +369,7 @@ void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败 assert.equal(apiErr.httpStatus, 200); }); -void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret 原文', async () => { +void test('auth.getAppAccessToken: 换取失败时错误信息不泄露 appSecret 原文', async () => { const secretMarker = 'DO-NOT-LEAK-app-secret-zzz999'; const {mockAgent, pool} = createMockAgent(); pool @@ -364,7 +382,7 @@ void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); const rendered = `${(err as Error).message ?? ''} ${String(err)} ${safeStringify(err)}`; assert.ok( !rendered.includes(secretMarker), @@ -372,7 +390,7 @@ void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret ); }); -void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token,且不进入 app 级缓存', async () => { +void test('auth.getUserAccessToken: AUTHORIZATION_CODE 正常换取用户级 token,且不进入 app 级缓存', async () => { const {mockAgent, pool} = createMockAgent(); let capturedBody: unknown; pool @@ -400,12 +418,12 @@ void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token, dispatcher: mockAgent, }); - const session = await client.auth.code2Session('login-code-123'); + const userCredential = await client.auth.getUserAccessToken('login-code-123'); - assert.equal(session.accessToken, 'user-token-xyz'); - assert.equal(session.refreshToken, 'refresh-xyz'); - assert.equal(session.openId, 'openid-abc'); - assert.equal(typeof session.expiresAt, 'string'); + assert.equal(userCredential.accessToken, 'user-token-xyz'); + assert.equal(userCredential.refreshToken, 'refresh-xyz'); + assert.equal(userCredential.openId, 'openid-abc'); + assert.equal(typeof userCredential.expiresAt, 'string'); assert.deepEqual(capturedBody, { grantType: 'AUTHORIZATION_CODE', appId: 'app-1', @@ -414,15 +432,15 @@ void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token, }); // 换取 app 级 token 时必须仍然真实发起一次独立的 CLIENT_CREDENTIALS 请求, - // 证明 code2Session 拿到的用户级 token 没有被误当成 app 级 token 缓存复用。 + // 证明 getUserAccessToken 拿到的用户级 token 没有被误当成 app 级 token 缓存复用。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) .reply( 200, businessSuccess(tokenSuccessData({accessToken: 'app-token-fresh'})), ); - const appToken = await client.auth.getAccessToken(); - assert.equal(appToken, 'app-token-fresh'); + const appToken = await client.auth.getAppAccessToken(); + assert.equal(appToken.accessToken, 'app-token-fresh'); }); void test('auth.refreshToken: 正常路径换取新 accessToken(一次性,不缓存)', async () => { @@ -457,7 +475,7 @@ void test('auth.refreshToken: 正常路径换取新 accessToken(一次性, assert.deepEqual(capturedBody, {refreshToken: 'some-refresh-token'}); }); -void test('auth.refreshToken: 空字符串 refreshToken 本地直接报错,不发起任何 HTTP 请求(与 code2Session 对 code 的校验保持一致)', async () => { +void test('auth.refreshToken: 空字符串 refreshToken 本地直接报错,不发起任何 HTTP 请求(与 getUserAccessToken 对 code 的校验保持一致)', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/refresh', method: 'POST'}) diff --git a/node/test/errors-code-sanitization.test.ts b/node/test/errors-code-sanitization.test.ts index 10bca42..ff7d96a 100644 --- a/node/test/errors-code-sanitization.test.ts +++ b/node/test/errors-code-sanitization.test.ts @@ -41,7 +41,7 @@ void test('QdmpApiError: code 字段中嵌入的 \\r\\n 不得原样出现在 er dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, @@ -84,7 +84,7 @@ void test('QdmpApiError: requestId 字段中嵌入的 \\r\\n 不得原样出现 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok(err instanceof QdmpApiError); const apiErr = err as QdmpApiError; @@ -116,7 +116,7 @@ void test('HttpClient.request: 响应体的 code 字段是数组/对象(既不 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, `应当 reject QdmpApiError,实际抛出: ${String(err)}`, diff --git a/node/test/errors-message-sanitization.test.ts b/node/test/errors-message-sanitization.test.ts index 0641579..0df3af1 100644 --- a/node/test/errors-message-sanitization.test.ts +++ b/node/test/errors-message-sanitization.test.ts @@ -45,7 +45,7 @@ void test('QdmpApiError: message 中嵌入的 \\r\\n 不得原样出现在 error .reply(200, businessFailure('40001', injectedMessage)); const client = makeClient(mockAgent); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, @@ -71,7 +71,7 @@ void test('QdmpApiError: 服务端返回超长 message(约20万字符)时, .reply(200, businessFailure('40002', hugeMessage)); const client = makeClient(mockAgent); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, diff --git a/node/test/helpers/mock-http.ts b/node/test/helpers/mock-http.ts index b2af5a2..f58a73d 100644 --- a/node/test/helpers/mock-http.ts +++ b/node/test/helpers/mock-http.ts @@ -62,6 +62,41 @@ export function gatewayFailure( return {code, message, details}; } +/** + * 用 `.reply()` 的回调形式来数"真的发出了几次请求"、顺便抓请求头。 + * + * 不能用 `intercept({headers: h => ...})` 的匹配函数来做这件事:undici 对同一次 + * 请求会多次调用它——挑选候选拦截器时每个未消费的候选各调一次 + * (mock-utils.js 的 getMockDispatch()),回收已消费的拦截器时再调一次 + * (同文件 deleteMockDispatch())。而 `.reply(opts => ...)` 的回调在 + * mockDispatch() 里严格每次真实 dispatch 只跑一次,是唯一可靠的请求计数点。 + */ +export function replyWith( + statusCode: number, + data: Record, + onRequest?: (headers: Record) => void, +): (opts: {headers?: Record | Headers}) => { + statusCode: number; + data: Record; +} { + return opts => { + onRequest?.(toHeaderRecord(opts.headers)); + return {statusCode, data}; + }; +} + +function toHeaderRecord( + headers: Record | Headers | undefined, +): Record { + if (!headers) { + return {}; + } + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + return headers; +} + /** * 统一捕获"函数应当失败"这件事,同时兼容同步 throw 和异步 reject 两种实现风格 * ——`assert.rejects()` 只在传入函数返回 rejected promise 时才生效,若实现是同步 diff --git a/node/test/user-credential.test.ts b/node/test/user-credential.test.ts new file mode 100644 index 0000000..bef0b50 --- /dev/null +++ b/node/test/user-credential.test.ts @@ -0,0 +1,915 @@ +/** + * withUserCredential():用户授权凭证自动续期测试。 + * + * 需求见 shared/contract(本次重构新增能力):`qdmp.withUserCredential(options)` + * 返回一个绑定了某个用户授权凭证(accessToken+refreshToken[+expiresAt])的 + * client,业务调用前会在需要时自动续期(提前 300 秒续期 / 401+10005/10006 + * 触发的续期重试),业务方法本身不再需要传 ctx。 + * + * + * mock 响应严格按已实测的服务端事实构造:响应字段驼峰、`expiresAt` 是字符串、 + * `code` 是数字 0(本项目 helpers/mock-http.ts 用字符串 `'0'` 表达 + * `String(code) === '0'` 这一唯一成功判据,与既有测试套件保持一致)、 + * `POST /auth/v1/refresh` 成功响应 data 只有 accessToken+expiresAt(不返回新 + * refreshToken)、无效 access-token 是 HTTP 401 + code 10005/10006、无效 + * refreshToken 是 HTTP 200 + code 10008。 + * + * 覆盖点(对应 CONTRACT.md §2.3 行为契约 1-10 条 + §2.4 + §2.5): + * - 本地校验:缺 refreshToken / 空 accessToken / 空 refreshToken 不发任何 HTTP 请求 + * - §2.3.1 提前续期(300 秒缓冲)+ expiresAt 未传时跳过提前续期 + * - §2.3.2 续期请求形状:body={refreshToken},noAuth(不带 access-token 头) + * - §2.3.2 refreshToken 续期后保持原值不变 + * - §2.3.3 onRefresh 只收到 {accessToken,expiresAt},必须被 await,抛错时整个 + * 业务调用失败但已更新的新 token 不回滚 + * - §2.3.4/5 401+10005/10006 触发恰好一次续期 + 恰好一次重试;重试后仍失败则 + * 直接抛错,不再续期 + * - §2.3.6 HTTP 5xx / 网络错误 / 非 401 业务错误 / 401 但 code 不在 + * {10005,10006} 一律不触发续期 + * - §2.3.7 续期本身失败时原样抛给调用方,不重试业务请求 + * - §2.3.8 并发业务调用时续期只发生一次(单飞) + * - §2.3.9 credential 是只读快照,外部改它不影响内部状态 + * - §2.3.10 credential 在 util.inspect()/console.log() 路径脱敏 + * - §2.4 业务方法不带 ctx(uc.user.me()、uc.mark.add({spuId})) + * - §2.5 genai 分组同样自动续期 + */ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {inspect} from 'node:util'; +import type {MockAgent} from 'undici'; + +import {QdmpClient} from '../src/client.js'; +import {QdmpApiError, QdmpValidationError} from '../src/errors.js'; +import { + businessFailure, + businessSuccess, + createMockAgent, + expectFailure, + gatewayFailure, + replyWith, +} from './helpers/mock-http.js'; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function makeRootClient(mockAgent: MockAgent) { + return new QdmpClient({ + appId: 'app-id-1', + appSecret: 'app-secret-1', + dispatcher: mockAgent, + }); +} + +// --------------------------------------------------------------------------- +// 本地校验:不发任何 HTTP 请求 +// --------------------------------------------------------------------------- + +void test('withUserCredential: accessToken 为空字符串时本地报错,不发任何 HTTP 请求', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessSuccess({id: 'should-not-be-reached'})); + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'should-not-be-used', + expiresAt: String(nowSeconds() + 7200), + }), + ); + + const qdmp = makeRootClient(mockAgent); + + const err = await expectFailure(async () => { + const uc = qdmp.withUserCredential({ + accessToken: '', + refreshToken: 'rt-1', + }); + await uc.user.me(); + }); + + assert.ok( + err instanceof QdmpValidationError, + `accessToken 为空字符串时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, + ); + assert.equal( + mockAgent.pendingInterceptors().length, + 2, + '本地校验失败时不应发出任何 HTTP 请求,业务接口和 refresh 接口的拦截器都必须仍未被消费', + ); +}); + +void test('withUserCredential: 缺少 refreshToken 时本地报错,不发任何 HTTP 请求', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessSuccess({id: 'should-not-be-reached'})); + + const qdmp = makeRootClient(mockAgent); + + const err = await expectFailure(async () => { + // @ts-expect-error 故意不传 refreshToken,验证运行时本地校验 + const uc = qdmp.withUserCredential({accessToken: 'at-1'}); + await uc.user.me(); + }); + + assert.ok( + err instanceof QdmpValidationError, + `缺少 refreshToken 时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, + ); + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + '本地校验失败时不应发出任何 HTTP 请求', + ); +}); + +void test('withUserCredential: refreshToken 为空字符串时本地报错(没有它就无法续期,等同于静态 token)', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessSuccess({id: 'should-not-be-reached'})); + + const qdmp = makeRootClient(mockAgent); + + const err = await expectFailure(async () => { + const uc = qdmp.withUserCredential({ + accessToken: 'at-1', + refreshToken: '', + }); + await uc.user.me(); + }); + + assert.ok( + err instanceof QdmpValidationError, + `refreshToken 为空字符串时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, + ); + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + '本地校验失败时不应发出任何 HTTP 请求', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.1 / §2.3.2:提前续期 + 续期请求本身的形状 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.1/§2.3.2: expiresAt 距过期 <=300 秒时业务调用前先续期,续期请求 body={refreshToken} 且不带 access-token 头', async t => { + t.mock.timers.enable({apis: ['Date']}); + const nowS = nowSeconds(); + const {mockAgent, pool} = createMockAgent(); + + let refreshBody: unknown; + let refreshHeaders: Record | undefined; + pool + .intercept({ + path: '/auth/v1/refresh', + method: 'POST', + body: raw => { + refreshBody = JSON.parse(raw as string); + return true; + }, + headers: h => { + refreshHeaders = h as Record; + return true; + }, + }) + .reply( + 200, + businessSuccess({ + accessToken: 'at-refreshed-1', + expiresAt: String(nowS + 7200), + }), + ); + + let businessHeaders: Record | undefined; + pool + .intercept({ + path: '/user/v1/me', + method: 'GET', + headers: h => { + businessHeaders = h as Record; + return true; + }, + }) + .reply(200, businessSuccess({id: 'u-1', nickname: 'Alice'})); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-stale-000', + refreshToken: 'rt-stable-000', + expiresAt: String(nowS + 200), // 距过期只剩 200 秒,落在 300 秒缓冲区内 + }); + + const me = await uc.user.me(); + + assert.equal(me.id, 'u-1'); + assert.deepEqual( + refreshBody, + {refreshToken: 'rt-stable-000'}, + '续期请求 body 必须只有原始 refreshToken 这一个字段', + ); + assert.equal( + refreshHeaders?.['access-token'], + undefined, + 'noAuth 方案的续期请求不应携带 access-token 头', + ); + assert.equal( + businessHeaders?.['access-token'], + 'at-refreshed-1', + '业务请求必须使用续期后的新 accessToken,而不是原来即将过期的旧值', + ); + assert.equal( + mockAgent.pendingInterceptors().length, + 0, + '续期请求和业务请求的拦截器都应当各被消费恰好一次', + ); +}); + +void test('withUserCredential §2.3.1: expiresAt 未传时跳过提前续期,首次调用直接发业务请求(refresh 端点 0 次请求)', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessSuccess({id: 'u-1'})); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-000', + refreshToken: 'rt-000', + // 不传 expiresAt + }); + + const me = await uc.user.me(); + + assert.equal(me.id, 'u-1'); + assert.equal( + mockAgent.pendingInterceptors().length, + 0, + '唯一注册的业务拦截器应当被消费;若实现误触发了续期,会因 refresh 端点没有注册拦截器而在 disableNetConnect 下报错,导致本次调用失败', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.2 / §2.3.3:refreshToken 保持不变 + onRefresh 参数/时机 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.2/§2.3.3: 续期后 refreshToken 保持原值不变,onRefresh 只收到 {accessToken,expiresAt}', async t => { + t.mock.timers.enable({apis: ['Date']}); + const nowS = nowSeconds(); + const {mockAgent, pool} = createMockAgent(); + + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'at-refreshed-2', + expiresAt: String(nowS + 7200), + }), + ); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessSuccess({id: 'u-1'})); + + const onRefreshCalls: unknown[] = []; + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-stale', + refreshToken: 'rt-must-not-change', + expiresAt: String(nowS + 100), + onRefresh: async token => { + onRefreshCalls.push(token); + }, + }); + + await uc.user.me(); + + assert.equal(onRefreshCalls.length, 1, 'onRefresh 应当恰好被调用一次'); + assert.deepEqual( + onRefreshCalls[0], + {accessToken: 'at-refreshed-2', expiresAt: String(nowS + 7200)}, + 'onRefresh 收到的参数必须只有 accessToken/expiresAt,不应包含 refreshToken(服务端 refresh 响应本来就不返回新 refreshToken)', + ); + assert.equal( + uc.credential.refreshToken, + 'rt-must-not-change', + '续期后 refreshToken 必须保持原值不变', + ); + assert.equal(uc.credential.accessToken, 'at-refreshed-2'); + assert.equal(uc.credential.expiresAt, String(nowS + 7200)); +}); + +void test('withUserCredential §2.3.3: onRefresh 必须被 await,业务请求要等 onRefresh resolve 之后才发出', async t => { + t.mock.timers.enable({apis: ['Date']}); + const nowS = nowSeconds(); + const {mockAgent, pool} = createMockAgent(); + + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'at-refreshed-3', + expiresAt: String(nowS + 7200), + }), + ); + + const order: string[] = []; + let releaseOnRefresh: () => void = () => {}; + const gate = new Promise(resolve => { + releaseOnRefresh = resolve; + }); + + pool.intercept({path: '/user/v1/me', method: 'GET'}).reply( + replyWith(200, businessSuccess({id: 'u-1'}), () => { + order.push('business-request-sent'); + }), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-stale', + refreshToken: 'rt-1', + expiresAt: String(nowS + 100), + onRefresh: async () => { + order.push('onRefresh-start'); + await gate; + order.push('onRefresh-end'); + }, + }); + + const callPromise = uc.user.me(); + + await new Promise(resolve => setTimeout(resolve, 20)); + assert.deepEqual( + order, + ['onRefresh-start'], + '业务请求不应在 onRefresh 完成之前就被发出', + ); + + releaseOnRefresh(); + await callPromise; + + assert.deepEqual( + order, + ['onRefresh-start', 'onRefresh-end', 'business-request-sent'], + 'onRefresh 必须被 await,业务请求必须严格排在 onRefresh resolve 之后', + ); +}); + +void test('withUserCredential §2.3.3: onRefresh 抛错时整个业务调用失败,但内部已更新的新 token 不回滚', async t => { + t.mock.timers.enable({apis: ['Date']}); + const nowS = nowSeconds(); + const {mockAgent, pool} = createMockAgent(); + + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'at-after-onrefresh-throw', + expiresAt: String(nowS + 7200), + }), + ); + // 故意不注册 /user/v1/me 拦截器:onRefresh 失败后业务请求本不应该被发出; + // 若实现仍然发出业务请求,disableNetConnect 会让它报出与预期不同的错误, + // 从而暴露"onRefresh 失败后仍继续发业务请求"这个 bug。 + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-stale', + refreshToken: 'rt-1', + expiresAt: String(nowS + 100), + onRefresh: () => { + throw new Error('db write failed'); + }, + }); + + const err = await expectFailure(() => uc.user.me()); + + assert.ok( + `${(err as Error)?.message ?? err}`.includes('db write failed'), + `业务调用应当以 onRefresh 抛出的错误失败,不能被吞掉,实际抛出: ${String(err)}`, + ); + assert.equal( + uc.credential.accessToken, + 'at-after-onrefresh-throw', + '即使 onRefresh 失败,已经换到手的新 accessToken 也不应回滚', + ); + assert.equal(uc.credential.expiresAt, String(nowS + 7200)); +}); + +// --------------------------------------------------------------------------- +// §2.3.4 / §2.3.5:401 续期重试 + 只重试一次 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.4/§2.3.5: 401+10006 触发恰好一次续期 + 恰好一次重试,用新 token 重试成功', async () => { + const {mockAgent, pool} = createMockAgent(); + + let businessCallCount = 0; + const capturedTokens: Array = []; + const countBusinessCall = (h: Record) => { + businessCallCount++; + capturedTokens.push(h['access-token']); + }; + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply( + replyWith( + 401, + businessFailure(10006, 'access_token 超过有效期'), + countBusinessCall, + ), + ); + + let refreshCallCount = 0; + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + replyWith( + 200, + businessSuccess({ + accessToken: 'at-after-401-retry', + expiresAt: String(nowSeconds() + 7200), + }), + () => { + refreshCallCount++; + }, + ), + ); + + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(replyWith(200, businessSuccess({id: 'u-1'}), countBusinessCall)); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-invalid', + refreshToken: 'rt-1', + // 不传 expiresAt:只靠 401 触发续期 + }); + + const me = await uc.user.me(); + + assert.equal(me.id, 'u-1'); + assert.equal(refreshCallCount, 1, '401+10006 应当恰好触发一次续期'); + assert.equal( + businessCallCount, + 2, + '业务请求应当恰好发生两次:首次失败 + 重试一次', + ); + assert.equal(capturedTokens[0], 'at-invalid'); + assert.equal( + capturedTokens[1], + 'at-after-401-retry', + '重试请求必须使用续期后的新 accessToken', + ); +}); + +void test('withUserCredential §2.3.5: 重试后仍然失败(同样是 401+10006)时直接抛出该错误,不再触发第二次续期', async () => { + const {mockAgent, pool} = createMockAgent(); + + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(401, businessFailure(10006, 'access_token 超过有效期')); + + let refreshCallCount = 0; + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + replyWith( + 200, + businessSuccess({ + accessToken: 'at-still-bad', + expiresAt: String(nowSeconds() + 7200), + }), + () => { + refreshCallCount++; + }, + ), + ); + + // 重试请求同样失败:若实现在重试失败后又发起了第二次续期,会因为 refresh + // 端点只注册了一个拦截器(已被上面的续期消费)而在 disableNetConnect 下 + // 报出与预期(10006)不同的错误,从而暴露"重试失败后仍继续续期"的 bug。 + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(401, businessFailure(10006, 'access_token 超过有效期(仍然无效)')); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-invalid', + refreshToken: 'rt-1', + }); + + const err = await expectFailure(() => uc.user.me()); + + assert.ok(err instanceof QdmpApiError); + const apiErr = err as QdmpApiError; + assert.equal(String(apiErr.code), '10006'); + assert.equal(apiErr.httpStatus, 401); + assert.equal(refreshCallCount, 1, '重试后仍失败时不应再触发第二次续期'); + assert.equal( + mockAgent.pendingInterceptors().length, + 0, + '三个拦截器(首次业务失败、续期、重试业务仍失败)应当各被消费恰好一次,不多不少', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.6:不该重试的一律不重试(也不触发续期) +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.6: HTTP 5xx 不触发续期,原样抛出错误', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(500, gatewayFailure(2, 'internal server error')); + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'should-not-be-used', + expiresAt: String(nowSeconds() + 7200), + }), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-1', + refreshToken: 'rt-1', + }); + + const err = await expectFailure(() => uc.user.me()); + + assert.ok(err instanceof QdmpApiError); + assert.equal((err as QdmpApiError).httpStatus, 500); + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + 'HTTP 5xx 不应触发续期,refresh 端点的拦截器必须仍未被消费', + ); +}); + +void test('withUserCredential §2.3.6: 网络/传输错误不触发续期,原样抛出错误', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .replyWithError(new Error('simulated network failure')); + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'should-not-be-used', + expiresAt: String(nowSeconds() + 7200), + }), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-1', + refreshToken: 'rt-1', + }); + + await expectFailure(() => uc.user.me()); + + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + '网络/传输错误不应触发续期,refresh 端点的拦截器必须仍未被消费', + ); +}); + +void test('withUserCredential §2.3.6: 非 401 的业务错误码不触发续期', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(200, businessFailure(10001, 'appId 无效')); + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'should-not-be-used', + expiresAt: String(nowSeconds() + 7200), + }), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-1', + refreshToken: 'rt-1', + }); + + const err = await expectFailure(() => uc.user.me()); + assert.ok(err instanceof QdmpApiError); + assert.equal(String((err as QdmpApiError).code), '10001'); + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + '非 401 的业务错误不应触发续期', + ); +}); + +void test('withUserCredential §2.3.6: HTTP 401 但业务 code 不是 10005/10006 时不触发续期', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(401, businessFailure(40001, '未知的 401 语义')); + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + 200, + businessSuccess({ + accessToken: 'should-not-be-used', + expiresAt: String(nowSeconds() + 7200), + }), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-1', + refreshToken: 'rt-1', + }); + + const err = await expectFailure(() => uc.user.me()); + assert.ok(err instanceof QdmpApiError); + assert.equal(String((err as QdmpApiError).code), '40001'); + assert.equal( + mockAgent.pendingInterceptors().length, + 1, + 'HTTP 401 但 code 不是 10005/10006 时不应触发续期', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.7:续期本身失败 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.7: 续期本身失败(refresh 返回 10008)时把该错误原样抛给调用方,且不重试业务请求', async () => { + const {mockAgent, pool} = createMockAgent(); + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply(401, businessFailure(10006, 'access_token 超过有效期')); + pool + .intercept({path: '/auth/v1/refresh', method: 'POST'}) + .reply(200, businessFailure(10008, 'refreshToken 超过有效期')); + // 故意不注册第二个 /user/v1/me 拦截器:续期失败后不应该发起业务重试; + // 若实现仍然重试,disableNetConnect 会让它报出与预期(10008)不同的错误。 + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-invalid', + refreshToken: 'rt-expired', + }); + + const err = await expectFailure(() => uc.user.me()); + + assert.ok(err instanceof QdmpApiError); + const apiErr = err as QdmpApiError; + assert.equal(String(apiErr.code), '10008'); + assert.equal( + mockAgent.pendingInterceptors().length, + 0, + '首次业务请求和续期请求的拦截器都应各被消费恰好一次,不应发生业务重试', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.8:并发单飞 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.8: 并发多个业务调用时续期只发生一次(单飞)', async () => { + const nowS = nowSeconds(); + const {mockAgent, pool} = createMockAgent(); + + let refreshCallCount = 0; + pool + .intercept({path: '/auth/v1/refresh', method: 'POST'}) + .reply( + replyWith( + 200, + businessSuccess({ + accessToken: 'at-single-flight', + expiresAt: String(nowS + 7200), + }), + () => { + refreshCallCount++; + }, + ), + ) + .delay(20); + + const capturedTokens: Array = []; + pool + .intercept({path: '/user/v1/me', method: 'GET'}) + .reply( + replyWith(200, businessSuccess({id: 'u-1'}), h => { + capturedTokens.push(h['access-token']); + }), + ) + .persist(); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-stale', + refreshToken: 'rt-1', + expiresAt: String(nowS + 100), // 落在续期缓冲区内,三个并发调用都会看到同样的"需要续期"状态 + }); + + const [r1, r2, r3] = await Promise.all([ + uc.user.me(), + uc.user.me(), + uc.user.me(), + ]); + + assert.equal(r1.id, 'u-1'); + assert.equal(r2.id, 'u-1'); + assert.equal(r3.id, 'u-1'); + assert.equal( + refreshCallCount, + 1, + '三个并发业务调用应当只触发一次真实的续期请求,其余调用应等待同一次续期结果', + ); + assert.equal(capturedTokens.length, 3); + for (const token of capturedTokens) { + assert.equal( + token, + 'at-single-flight', + '所有并发调用最终都应使用同一次续期换来的新 accessToken', + ); + } +}); + +// --------------------------------------------------------------------------- +// §2.3.9:credential 快照 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.9: credential 返回快照,外部修改它不影响内部状态', async () => { + const {mockAgent, pool} = createMockAgent(); + + let capturedToken: string | undefined; + pool + .intercept({ + path: '/user/v1/me', + method: 'GET', + headers: h => { + capturedToken = (h as Record)['access-token']; + return true; + }, + }) + .reply(200, businessSuccess({id: 'u-1'})) + .persist(); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-real-000', + refreshToken: 'rt-real-000', + }); + + await uc.user.me(); + assert.equal(capturedToken, 'at-real-000'); + + const snapshot = uc.credential as { + accessToken: string; + refreshToken: string; + }; + snapshot.accessToken = 'tampered-access-token'; + snapshot.refreshToken = 'tampered-refresh-token'; + + assert.equal( + uc.credential.accessToken, + 'at-real-000', + 'credential 必须是快照/不可变副本,外部改它不应污染内部状态', + ); + + await uc.user.me(); + assert.equal( + capturedToken, + 'at-real-000', + '篡改快照后再次发起业务调用,仍应使用未被污染的真实内部 accessToken', + ); +}); + +// --------------------------------------------------------------------------- +// §2.3.10:调试打印路径脱敏 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.3.10: uc.credential 的 util.inspect()/console.log() 路径必须脱敏,不暴露 accessToken/refreshToken 明文', () => { + const SENTINEL_ACCESS = 'sentinel-uc-access-do-not-leak-abc123'; + const SENTINEL_REFRESH = 'sentinel-uc-refresh-do-not-leak-abc123'; + const {mockAgent} = createMockAgent(); + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: SENTINEL_ACCESS, + refreshToken: SENTINEL_REFRESH, + }); + + const rendered = inspect(uc.credential); + assert.ok( + !rendered.includes(SENTINEL_ACCESS), + `util.inspect(uc.credential) 泄露了 accessToken 明文: ${rendered}`, + ); + assert.ok( + !rendered.includes(SENTINEL_REFRESH), + `util.inspect(uc.credential) 泄露了 refreshToken 明文: ${rendered}`, + ); + + // 反向验证:直接属性访问必须仍是明文,否则调用方无法在进程退出前落库。 + assert.equal(uc.credential.accessToken, SENTINEL_ACCESS); + assert.equal(uc.credential.refreshToken, SENTINEL_REFRESH); +}); + +// --------------------------------------------------------------------------- +// §2.4:业务方法不带 ctx +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.4: uc.mark.add 业务方法不需要 ctx 参数,只传业务 body', async () => { + const {mockAgent, pool} = createMockAgent(); + let capturedBody: unknown; + let capturedHeaders: Record | undefined; + pool + .intercept({ + path: '/mark/v1/add', + method: 'POST', + body: raw => { + capturedBody = JSON.parse(raw as string); + return true; + }, + headers: h => { + capturedHeaders = h as Record; + return true; + }, + }) + .reply(200, businessSuccess({id: 'mark-1'})); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-mark-1', + refreshToken: 'rt-mark-1', + }); + + const result = await uc.mark.add({spuId: '12345'}); + + assert.equal(result.id, 'mark-1'); + assert.deepEqual(capturedBody, {spuId: '12345'}); + assert.equal( + capturedHeaders?.['access-token'], + 'at-mark-1', + 'uc.mark.add 只传业务 body,accessToken 应当由 withUserCredential 内部自动附加', + ); +}); + +// --------------------------------------------------------------------------- +// §2.5:genai 分组同样自动续期 +// --------------------------------------------------------------------------- + +void test('withUserCredential §2.5: genai 分组同样自动续期,401+10006 触发续期后用新 token 重试', async () => { + const {mockAgent, pool} = createMockAgent(); + + let firstAttemptCount = 0; + pool + .intercept({path: '/genai/v1/detail', method: 'GET', query: {id: 'task-1'}}) + .reply( + replyWith(401, businessFailure(10006, 'access_token 超过有效期'), () => { + firstAttemptCount++; + }), + ); + + let refreshCallCount = 0; + pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( + replyWith( + 200, + businessSuccess({ + accessToken: 'at-genai-refreshed', + expiresAt: String(nowSeconds() + 7200), + }), + () => { + refreshCallCount++; + }, + ), + ); + + let retryHeaders: Record | undefined; + pool + .intercept({path: '/genai/v1/detail', method: 'GET', query: {id: 'task-1'}}) + .reply( + replyWith( + 200, + businessSuccess({id: 'task-1', status: 'COMPLETED'}), + h => { + retryHeaders = h; + }, + ), + ); + + const qdmp = makeRootClient(mockAgent); + const uc = qdmp.withUserCredential({ + accessToken: 'at-genai-invalid', + refreshToken: 'rt-genai-1', + }); + + const result = await uc.genai.detail({id: 'task-1'}); + + assert.equal(result.id, 'task-1'); + assert.equal(firstAttemptCount, 1); + assert.equal( + refreshCallCount, + 1, + 'genai 分组的 401+10006 也应当恰好触发一次续期', + ); + assert.equal( + retryHeaders?.['x-openapi-access-token'], + 'at-genai-refreshed', + 'genai 重试请求必须携带续期后的新 accessToken(走 x-openapi-access-token 头)', + ); + assert.equal( + retryHeaders?.['access-token'], + undefined, + 'genai 方案不应携带 standard 方案专用的 access-token 头', + ); +}); diff --git a/shared/generated/error-codes.json b/shared/generated/error-codes.json index 639685d..cdd9edb 100644 --- a/shared/generated/error-codes.json +++ b/shared/generated/error-codes.json @@ -14,8 +14,8 @@ { "code": 10003, "name": "AUTH_CODE_INVALID", - "message": "授权码错误", - "source": "文档" + "message": "授权码错误或超过 5 分钟", + "source": "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)" }, { "code": 10004, diff --git a/shared/openapi.yaml b/shared/openapi.yaml index 2b5e601..4828949 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -27,8 +27,8 @@ { "code": 10003, "name": "AUTH_CODE_INVALID", - "message": "授权码错误", - "source": "文档" + "message": "授权码错误或超过 5 分钟", + "source": "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)" }, { "code": 10004, @@ -103,7 +103,7 @@ "auth" ], "summary": "获取访问令牌", - "description": "支持两种授权模式:CLIENT_CREDENTIALS(客户端凭证)和 AUTHORIZATION_CODE(授权码,用户登录后)。", + "description": "签发两种凭证:CLIENT_CREDENTIALS 换应用凭证,代表这个开放平台应用本身,不代表任何用户,用于开发者内部调试、服务端联调、不需要用户身份的后台任务;AUTHORIZATION_CODE 换用户授权凭证,绑定 openId,代表某个真实登录用户,应用上线后调业务接口全程用它。", "x-qdmp-auth-scheme": "noAuth", "x-qdmp-token-required": false, "parameters": [], @@ -120,7 +120,7 @@ "CLIENT_CREDENTIALS", "AUTHORIZATION_CODE" ], - "description": "授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE" + "description": "凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证)" }, "appId": { "type": "string", @@ -132,7 +132,7 @@ }, "code": { "type": "string", - "description": "授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" + "description": "一次性授权码,由前端/App 持用户登录态调 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:\"openapi\"})换取,取响应的 data.code。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" } }, "required": [ @@ -557,7 +557,7 @@ ], "responses": { "200": { - "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个 token(见 x-qdmp-token-required=true),是否传 app 级还是用户级由调用方决定,不是 SDK 自动 fallback。", + "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个凭证(见 x-qdmp-token-required=true),传应用凭证还是用户授权凭证由调用方决定,不是 SDK 自动 fallback。", "content": { "application/json": { "schema": { @@ -1008,7 +1008,7 @@ ], "responses": { "200": { - "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 可真实调通本接口(HTTP 200 正常业务响应)。", + "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证可真实调通本接口(HTTP 200 正常业务响应)。", "content": { "application/json": { "schema": { @@ -3448,7 +3448,7 @@ "schema": { "type": "string" }, - "description": "用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。" + "description": "用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。" }, "QdmpVersionHeader": { "name": "x-echo-qdmp-version", From bcf75e458dcb3532a6deb380385142705512be13 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 21:13:11 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20=E6=8E=88=E6=9D=83=E7=A0=81?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E6=94=B9=E5=9B=9E=20qd.login()=EF=BC=8CREADM?= =?UTF-8?q?E=20=E5=8E=BB=E6=8E=89=E5=B9=B3=E5=8F=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 +------------------ go/generated/types.gen.go | 2 +- .../qdmp/generated/AuthTokenRequest.java | 2 +- node/src/generated/schema.d.ts | 2 +- shared/openapi.yaml | 2 +- 5 files changed, 5 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index faf6212..fa30fa8 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,6 @@ [千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api/auth-token) 官方 Server SDK,[Node.js](#nodejs)、[Java](#java)、[Go](#go) 三端实现,统一凭证生命周期管理 + 类型安全的业务接口封装。 -## 两种凭证 - -OpenAPI 的 token 都由 Auth 服务的 `POST /auth/v1/token` 签发,按使用场景分两种: - -| | 应用凭证 | 用户授权凭证 | -|---|---|---| -| `grantType` | `CLIENT_CREDENTIALS` | `AUTHORIZATION_CODE` | -| 代表谁 | 这个开放平台应用,不代表任何用户 | 某个真实登录用户,绑定 `openId` | -| 用在哪 | 开发者内部调试、服务端联调、不需要用户身份的后台任务 | 应用上线后代表用户调业务接口——**日常调用全程用它** | -| 怎么拿 | `appId` + `appSecret` 直接换 | 先拿一次性授权码,再用 `appId` + `appSecret` + `code` 换 | -| 有效期 | accessToken 7200 秒 | accessToken 7200 秒,refreshToken 2592000 秒(30 天) | - -应用凭证换不到用户身份,也调不了需要校验用户授权的接口,别拿它顶替用户授权凭证。 - -## 完整流程 - -用户授权凭证要两步换,第一步在前端、第二步在你的服务端: - -1. **前端/App 拿一次性授权码**:持用户登录态请求 `POST https://api.qiandao.com/signin/openapi/auth/generate-code`, - Header 带 `Authorization: Bearer <用户登录态 token>`,Body 传 `{"appId": "app_xxx", "scene": "openapi"}`, - 拿到 `data.code`。这一步需要用户登录态,本 SDK 不封装。 -2. **服务端换用户授权凭证**:把 `code` 传给下面的 `getUserAccessToken`。授权码是一次性的,**5 分钟内有效**,被 Auth 消费后立即失效。 -3. **调业务接口**:用换到的 accessToken,过期前 SDK 自动拿 refreshToken 续期。 - ## Node.js ```bash @@ -40,7 +16,7 @@ const qdmp = new QdmpClient({ appSecret: process.env.QDMP_APP_SECRET!, }) -// 前端传来一次性授权码,服务端换用户授权凭证并落库 +// 前端 qd.login() 拿到的一次性授权码传到服务端,换用户授权凭证并落库 const credential = await qdmp.auth.getUserAccessToken(code) await db.saveCredential(credential.openId, credential) diff --git a/go/generated/types.gen.go b/go/generated/types.gen.go index 3ee023b..d5b03c5 100644 --- a/go/generated/types.gen.go +++ b/go/generated/types.gen.go @@ -119,7 +119,7 @@ type AuthTokenJSONBody struct { // AppSecret 应用密钥 AppSecret string `json:"appSecret"` - // Code 一次性授权码,由前端/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 一次性授权码,小程序前端调宿主的 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(用户授权凭证) 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 9a7afe7..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 @@ -164,7 +164,7 @@ public AuthTokenRequest code(@javax.annotation.Nullable String code) { } /** - * 一次性授权码,由前端/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 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 + * 一次性授权码,小程序前端调宿主的 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/node/src/generated/schema.d.ts b/node/src/generated/schema.d.ts index 81ea2b0..7dd588c 100644 --- a/node/src/generated/schema.d.ts +++ b/node/src/generated/schema.d.ts @@ -589,7 +589,7 @@ export interface operations { appId: string; /** @description 应用密钥 */ appSecret: string; - /** @description 一次性授权码,由前端/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 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ + /** @description 一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:"openapi"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ code?: string; }; }; diff --git a/shared/openapi.yaml b/shared/openapi.yaml index 4828949..96f6b81 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -132,7 +132,7 @@ }, "code": { "type": "string", - "description": "一次性授权码,由前端/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 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" + "description": "一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:\"openapi\"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" } }, "required": [ From 7e9635c752c3056e070623adb1490225b45beccd Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 21:22:25 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20=E7=A4=BA=E4=BE=8B=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=81=87=E8=AE=BE=E8=B0=83=E7=94=A8=E6=96=B9=E6=9C=89?= =?UTF-8?q?=20db=EF=BC=8C=E5=87=AD=E8=AF=81=E6=80=8E=E4=B9=88=E5=AD=98?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E8=B0=83=E7=94=A8=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fa30fa8..9d1350e 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,16 @@ const qdmp = new QdmpClient({ appSecret: process.env.QDMP_APP_SECRET!, }) -// 前端 qd.login() 拿到的一次性授权码传到服务端,换用户授权凭证并落库 +// 前端 qd.login() 拿到的一次性授权码传到服务端,换用户授权凭证 const credential = await qdmp.auth.getUserAccessToken(code) -await db.saveCredential(credential.openId, credential) +// => { accessToken, refreshToken, expiresAt, openId },自己按 openId 存起来 -// 绑定用户授权凭证,之后业务调用不用再传 token;到期前自动续期,续期后回写你的库 +// 绑定凭证,之后业务调用不用再传 token;到期前自动续期 const asUser = qdmp.withUserCredential({ ...credential, - onRefresh: token => db.updateCredential(credential.openId, token), + onRefresh: token => { + // 续期后拿到新的 token.accessToken / token.expiresAt,存回你放凭证的地方 + }, }) const me = await asUser.user.me() @@ -59,14 +61,15 @@ QdmpClient qdmp = new QdmpClient( .build()); UserAccessTokenResult credential = qdmp.auth().getUserAccessToken(code); -db.saveCredential(credential.getOpenId(), credential); QdmpUserClient asUser = qdmp.withUserCredential( UserCredentialOptions.builder() .accessToken(credential.getAccessToken()) .refreshToken(credential.getRefreshToken()) .expiresAt(credential.getExpiresAtEpochSeconds()) - .onRefresh(token -> db.updateCredential(credential.getOpenId(), token)) + .onRefresh(token -> { + // 续期后把新的 accessToken / expiresAt 存回你放凭证的地方 + }) .build()); UserMe200ResponseAllOfData me = asUser.user().me(); @@ -90,14 +93,14 @@ client, err := qdmp.NewClient(qdmp.ClientOptions{ }) credential, err := client.Auth.GetUserAccessToken(ctx, code) -db.SaveCredential(credential.OpenID, credential) asUser := client.WithUserCredential(qdmp.UserCredentialOptions{ AccessToken: credential.AccessToken, RefreshToken: credential.RefreshToken, ExpiresAt: credential.ExpiresAt, OnRefresh: func(ctx context.Context, t qdmp.RefreshedUserToken) error { - return db.UpdateCredential(credential.OpenID, t) + // 续期后把新的 t.AccessToken / t.ExpiresAt 存回你放凭证的地方 + return nil }, }) @@ -113,8 +116,8 @@ appCredential, err := client.Auth.GetAppAccessToken(ctx) - **用户授权凭证**(主线):`withUserCredential` 绑定一份凭证后,SDK 到期前 300 秒自动用 refreshToken 续期; 业务调用撞上 HTTP 401 + `10005`/`10006`(access_token 失效/过期)时,续期一次并重试该请求一次。 - 续期成功后回调 `onRefresh`,把新 accessToken 落到你自己的库里——SDK 不替你持久化,一个进程服务海量终端用户, - 只有你的 session/DB 知道这次调用属于哪个用户。同一份凭证上的并发调用只会触发一次续期。 + 续期成功后回调 `onRefresh` 把新的 accessToken 交给你——SDK 不替你持久化:一个进程要服务海量终端用户, + 哪次调用属于哪个用户、凭证该存到哪,只有你自己知道。同一份凭证上的并发调用只会触发一次续期。 - **续期不换 refreshToken**:`/auth/v1/refresh` 只返回新的 accessToken 和 expiresAt,refreshToken 保持不变。 它本身失效时返回 HTTP 200 + `10007`/`10008`,此时凭证已无法挽救,需要重新走一次「拿授权码 → 换凭证」。 - **应用凭证**:`getAppAccessToken()` 自动缓存 + 到期前 300 秒重新换取 + 单飞锁防并发重复换取, From adf3fa75879f33c5993d5ae4779c2d4ea512ff88 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 21:56:04 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=E5=8E=BB=E6=8E=89=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BB=AD=E6=9C=9F=EF=BC=8C=E5=87=AD=E8=AF=81=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E4=BA=A4=E5=9B=9E=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 66 +- go/auth.go | 9 +- go/client.go | 53 +- go/errors.go | 8 - go/genai.go | 22 +- go/island.go | 11 +- go/mark.go | 44 +- go/spu.go | 22 +- go/tag.go | 22 +- go/user.go | 9 +- go/usercredential.go | 380 ------- go/usercredential_test.go | 935 ------------------ go/wishspu.go | 33 +- .../echotechfe/qdmp/AccessTokenSource.java | 44 - .../io/github/echotechfe/qdmp/QdmpClient.java | 29 +- .../github/echotechfe/qdmp/QdmpContext.java | 27 +- .../github/echotechfe/qdmp/QdmpTransport.java | 85 +- .../echotechfe/qdmp/QdmpUserClient.java | 381 ------- .../qdmp/StaticAccessTokenSource.java | 30 - .../github/echotechfe/qdmp/auth/QdmpAuth.java | 9 +- .../qdmp/auth/RefreshedUserToken.java | 56 -- .../qdmp/auth/UserCredentialOptions.java | 170 ---- .../qdmp/auth/UserCredentialSession.java | 131 --- .../qdmp/auth/UserCredentialSnapshot.java | 69 -- .../qdmp/auth/QdmpAuthResultTypesTest.java | 36 +- ...QdmpAuthUserAccessTokenAndRefreshTest.java | 20 +- .../qdmp/auth/QdmpUserCredentialTest.java | 703 ------------- node/src/client.ts | 26 - node/src/http.ts | 83 +- node/src/index.ts | 7 - node/src/user-client.ts | 119 --- node/src/user-credential.ts | 192 ---- node/test/user-credential.test.ts | 915 ----------------- 33 files changed, 183 insertions(+), 4563 deletions(-) delete mode 100644 go/usercredential.go delete mode 100644 go/usercredential_test.go delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java delete mode 100644 java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java delete mode 100644 java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java delete mode 100644 node/src/user-client.ts delete mode 100644 node/src/user-credential.ts delete mode 100644 node/test/user-credential.test.ts diff --git a/README.md b/README.md index 9d1350e..71590ed 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) 三端实现,统一凭证生命周期管理 + 类型安全的业务接口封装。 +[千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api/auth-token) 官方 Server SDK,[Node.js](#nodejs)、[Java](#java)、[Go](#go) 三端实现,类型安全的业务接口封装 + 应用凭证自动缓存。 ## Node.js @@ -20,19 +20,16 @@ const qdmp = new QdmpClient({ const credential = await qdmp.auth.getUserAccessToken(code) // => { accessToken, refreshToken, expiresAt, openId },自己按 openId 存起来 -// 绑定凭证,之后业务调用不用再传 token;到期前自动续期 -const asUser = qdmp.withUserCredential({ - ...credential, - onRefresh: token => { - // 续期后拿到新的 token.accessToken / token.expiresAt,存回你放凭证的地方 - }, -}) +// 调业务接口:accessToken 每次显式传进去 +const me = await qdmp.user.me({ accessToken: credential.accessToken }) +await qdmp.mark.add({ accessToken: credential.accessToken }, { spuId: '123', rating: { value: 5 } }) -const me = await asUser.user.me() -await asUser.mark.add({ 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 asUser.wishspu.list({ offset: '0', limit: '20' }) + 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) @@ -62,18 +59,14 @@ QdmpClient qdmp = new QdmpClient( UserAccessTokenResult credential = qdmp.auth().getUserAccessToken(code); -QdmpUserClient asUser = qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken(credential.getAccessToken()) - .refreshToken(credential.getRefreshToken()) - .expiresAt(credential.getExpiresAtEpochSeconds()) - .onRefresh(token -> { - // 续期后把新的 accessToken / expiresAt 存回你放凭证的地方 - }) - .build()); +// QdmpContext.of(accessToken) 是唯一入口,空/非法 token 在构造期就失败 +QdmpContext ctx = QdmpContext.of(credential.getAccessToken()); +UserMe200ResponseAllOfData me = qdmp.user().me(ctx); +qdmp.mark().add(ctx, new MarkAddRequest().spuId("123")); -UserMe200ResponseAllOfData me = asUser.user().me(); -asUser.mark().add(new MarkAddRequest().spuId("123")); +// 过期了自己换,SDK 不代管 +RefreshTokenResult fresh = qdmp.auth().refreshToken(credential.getRefreshToken()); +qdmp.user().me(QdmpContext.of(fresh.getAccessToken())); AppAccessTokenResult appCredential = qdmp.auth().getAppAccessToken(); ``` @@ -94,19 +87,15 @@ client, err := qdmp.NewClient(qdmp.ClientOptions{ credential, err := client.Auth.GetUserAccessToken(ctx, code) -asUser := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: credential.AccessToken, - RefreshToken: credential.RefreshToken, - ExpiresAt: credential.ExpiresAt, - OnRefresh: func(ctx context.Context, t qdmp.RefreshedUserToken) error { - // 续期后把新的 t.AccessToken / t.ExpiresAt 存回你放凭证的地方 - return nil - }, -}) - +// WithAccessToken 派生一个绑定了该 token 的子 client,拿不到 token 就构造不出可用的调用入口 +asUser := client.WithAccessToken(credential.AccessToken) me, err := asUser.User.Me(ctx) _, err = asUser.Mark.Add(ctx, generated.MarkAddJSONBody{SpuId: "123"}) +// 过期了自己换,SDK 不代管 +fresh, err := client.Auth.RefreshToken(ctx, credential.RefreshToken) +me, err = client.WithAccessToken(fresh.AccessToken).User.Me(ctx) + appCredential, err := client.Auth.GetAppAccessToken(ctx) ``` @@ -114,19 +103,20 @@ appCredential, err := client.Auth.GetAppAccessToken(ctx) ## 凭证生命周期模型 -- **用户授权凭证**(主线):`withUserCredential` 绑定一份凭证后,SDK 到期前 300 秒自动用 refreshToken 续期; - 业务调用撞上 HTTP 401 + `10005`/`10006`(access_token 失效/过期)时,续期一次并重试该请求一次。 - 续期成功后回调 `onRefresh` 把新的 accessToken 交给你——SDK 不替你持久化:一个进程要服务海量终端用户, - 哪次调用属于哪个用户、凭证该存到哪,只有你自己知道。同一份凭证上的并发调用只会触发一次续期。 -- **续期不换 refreshToken**:`/auth/v1/refresh` 只返回新的 accessToken 和 expiresAt,refreshToken 保持不变。 +- **用户授权凭证由调用方自己管**: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`。 -- 除上面那次 401 续期重试外,SDK 不做任何自动重试。 ## 各分组凭证要求 diff --git a/go/auth.go b/go/auth.go index b0d54ee..e2d5e5f 100644 --- a/go/auth.go +++ b/go/auth.go @@ -18,7 +18,7 @@ import ( const tokenRefreshBufferSeconds = 300 // UserAccessTokenResult is the one-shot AUTHORIZATION_CODE exchange result. -// The SDK never caches this — the caller's own session/DB persists it, +// The SDK never caches this — the caller's own store/DB persists it, // scoped to whichever end-user the code belonged to. type UserAccessTokenResult struct { AccessToken string `json:"accessToken"` @@ -410,9 +410,10 @@ func (a *AuthService) GetUserAccessToken(ctx context.Context, code string) (*Use } // 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.getUserAccessToken response has an already-expired expiresAt (%d)", expiresAt) } diff --git a/go/client.go b/go/client.go index 487eb2b..9dd4c84 100644 --- a/go/client.go +++ b/go/client.go @@ -1,8 +1,6 @@ package qdmp import ( - "context" - "encoding/json" "fmt" "net" "net/http" @@ -193,14 +191,14 @@ 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. -// A UserClient derived from WithUserCredential additionally carries a -// userCredential, which renews the access token on its own (see -// usercredential.go); one derived from WithAccessToken has a nil cred and -// keeps its static, never-renewed token. +// +// 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 - cred *userCredential User *UserGroup Island *IslandGroup @@ -231,50 +229,15 @@ func (c *Client) WithAccessToken(token string) *UserClient { // group method performs before touching the network: shared/generated/ // route-meta.json marks every non-auth operation x-qdmp-token-required=true, // so an empty token here always means "must not call this operation". -// -// For a WithUserCredential client this checks the credential's *current* -// access token (which a renewal may have replaced since construction), and -// additionally requires the refresh token that makes renewal possible at all. func requireAccessToken(uc *UserClient, opName string) error { - current := uc.Credential() - if current.AccessToken == "" { + if uc.accessToken == "" { return fmt.Errorf("qdmp: %s requires an access token: %w", opName, ErrAccessTokenRequired) } - for i := 0; i < len(current.AccessToken); i++ { - b := current.AccessToken[i] + for i := 0; i < len(uc.accessToken); i++ { + b := uc.accessToken[i] if b <= 0x1F || b == 0x7F { return fmt.Errorf("qdmp: %s: %w", opName, ErrInvalidAccessToken) } } - if uc.cred != nil && current.RefreshToken == "" { - return fmt.Errorf("qdmp: %s requires a refresh token: %w", opName, ErrRefreshTokenRequired) - } return nil } - -// doRequest is the single transport chokepoint every business group method -// goes through. Routing all seven groups here (instead of letting each call -// Client.doRequest directly) is what makes token renewal and the 401 retry -// a property of the client rather than something each operation has to -// remember to implement. -func (uc *UserClient) doRequest(ctx context.Context, p requestParams) (json.RawMessage, error) { - if uc.cred == nil { - p.accessToken = uc.accessToken - return uc.client.doRequest(ctx, p) - } - return uc.cred.do(ctx, p) -} - -// Credential returns a snapshot of the credential this client currently -// holds, so a caller can persist a renewed access token before its process -// exits. The returned value is an independent copy: it never changes as the -// client renews, and changing it never affects the client. -// -// For a WithAccessToken client (no renewal, no refresh token) only -// AccessToken is populated. -func (uc *UserClient) Credential() UserCredential { - if uc.cred == nil { - return UserCredential{AccessToken: uc.accessToken} - } - return uc.cred.snapshot() -} diff --git a/go/errors.go b/go/errors.go index e16f267..c6ad592 100644 --- a/go/errors.go +++ b/go/errors.go @@ -20,14 +20,6 @@ var ErrAccessTokenRequired = errors.New("qdmp: access token is required") // returned. var ErrInvalidAccessToken = errors.New("qdmp: access token contains a character that cannot be safely sent as an HTTP header value") -// ErrRefreshTokenRequired is a sentinel error returned when a client derived -// from WithUserCredential was given an empty RefreshToken. Renewal is the -// whole point of that constructor and is impossible without one, so this -// fails locally on the first business call, before any HTTP request — a -// caller that genuinely has only an access token wants WithAccessToken -// instead. -var ErrRefreshTokenRequired = errors.New("qdmp: refresh token is required") - // QdmpApiError represents a business-level failure reported by the qdmp // OpenAPI, as opposed to a transport-level failure (network error, etc). // diff --git a/go/genai.go b/go/genai.go index 376f172..66864c6 100644 --- a/go/genai.go +++ b/go/genai.go @@ -38,11 +38,12 @@ func (g *GenAIGroup) Generate(ctx context.Context, body generated.GenaiGenerateJ return nil, err } - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/genai/v1/generate", - jsonBody: body, - authScheme: authSchemeGenai, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/genai/v1/generate", + jsonBody: body, + authScheme: authSchemeGenai, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -69,11 +70,12 @@ func (g *GenAIGroup) Detail(ctx context.Context, params generated.GenaiDetailPar query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/genai/v1/detail", - query: query, - authScheme: authSchemeGenai, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/genai/v1/detail", + query: query, + authScheme: authSchemeGenai, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/island.go b/go/island.go index e9cb6ee..9ee945d 100644 --- a/go/island.go +++ b/go/island.go @@ -41,11 +41,12 @@ func (g *IslandGroup) Detail(ctx context.Context, params generated.IslandDetailP query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/island/v1/detail", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/island/v1/detail", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/mark.go b/go/mark.go index 86264be..e21fdac 100644 --- a/go/mark.go +++ b/go/mark.go @@ -83,11 +83,12 @@ func (g *MarkGroup) Add(ctx context.Context, body generated.MarkAddJSONBody) (*M return nil, err } - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/mark/v1/add", - jsonBody: body, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/mark/v1/add", + jsonBody: body, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -110,11 +111,12 @@ func (g *MarkGroup) List(ctx context.Context, params generated.MarkListParams) ( query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/list", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/list", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -138,11 +140,12 @@ func (g *MarkGroup) Search(ctx context.Context, params generated.MarkSearchParam query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/search", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/search", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -166,11 +169,12 @@ func (g *MarkGroup) Detail(ctx context.Context, params generated.MarkDetailParam query.Set("limit", params.Limit) query.Set("offset", params.Offset) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/mark/v1/me/detail", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/mark/v1/me/detail", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/spu.go b/go/spu.go index 04078bb..f2517d1 100644 --- a/go/spu.go +++ b/go/spu.go @@ -61,11 +61,12 @@ func (g *SpuGroup) Detail(ctx context.Context, params generated.SpuDetailParams) query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/spu/v1/detail", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/spu/v1/detail", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -107,11 +108,12 @@ func (g *SpuGroup) Search(ctx context.Context, params generated.SpuSearchParams) setIfNotNil(query, "offset", params.Offset) setIfNotNil(query, "limit", params.Limit) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/spu/v1/search", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/spu/v1/search", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/tag.go b/go/tag.go index 4cca04a..8fd4e8c 100644 --- a/go/tag.go +++ b/go/tag.go @@ -46,11 +46,12 @@ func (g *TagGroup) Detail(ctx context.Context, params generated.TagDetailParams) query := url.Values{} query.Set("id", params.Id) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/tag/v1/detail", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/tag/v1/detail", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -77,11 +78,12 @@ func (g *TagGroup) Search(ctx context.Context, params generated.TagSearchParams) setIfNotNil(query, "offset", params.Offset) setIfNotNil(query, "limit", params.Limit) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/tag/v1/search", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/tag/v1/search", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/user.go b/go/user.go index 2989571..629e9cf 100644 --- a/go/user.go +++ b/go/user.go @@ -31,10 +31,11 @@ func (g *UserGroup) Me(ctx context.Context) (*UserMeResult, error) { return nil, err } - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/user/v1/me", - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/user/v1/me", + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/go/usercredential.go b/go/usercredential.go deleted file mode 100644 index 13c0d02..0000000 --- a/go/usercredential.go +++ /dev/null @@ -1,380 +0,0 @@ -package qdmp - -// Automatic renewal of a user-level credential ("用户授权凭证"). -// -// A user access token lives 7200 seconds; the refresh token that renews it -// lives 30 days. Rather than making every caller notice expiry themselves, -// WithUserCredential derives a *UserClient that renews on two triggers: -// -// - before a business call, when the known expiry is within -// tokenRefreshBufferSeconds (the same 300s buffer the app-level -// credential uses); -// - after a business call comes back HTTP 401 with business code 10005 -// ("access_token 格式错误或已吊销") or 10006 ("access_token 超过有效期"), -// in which case the original request is retried exactly once. -// -// Nothing else renews. A 5xx, a transport error, an unrelated business -// error, or even a 401 carrying some other code are all handed back to the -// caller untouched — renewing on those would burn refresh attempts on -// failures a new token cannot fix. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "sync" - "time" -) - -// The two business codes that mean "this access token is no longer usable, -// get a new one" (see shared/generated/error-codes.json). Both are reported -// with HTTP 401. -const ( - codeAccessTokenRevoked = "10005" - codeAccessTokenExpired = "10006" -) - -// UserCredentialOptions configures Client.WithUserCredential. -type UserCredentialOptions struct { - // AccessToken is the current user access token. Required. - AccessToken string - // RefreshToken is the token used to renew AccessToken. Required: without - // it renewal is impossible, and a caller holding only an access token - // wants WithAccessToken instead. - RefreshToken string - // ExpiresAt is AccessToken's absolute expiry as Unix seconds in a string, - // exactly as the server sent it. Optional: leaving it empty means "expiry - // unknown", which skips proactive renewal entirely and leaves a 401 as - // the only renewal trigger. - ExpiresAt string - // OnRefresh, when set, is called after a successful renewal has already - // been applied to the client's own state, so the caller can persist the - // new token. It receives the context of the business call that triggered - // the renewal. Returning an error fails that business call with that - // error; the renewed token is kept regardless (it has already been issued - // — discarding it would only force another renewal). - OnRefresh func(ctx context.Context, token RefreshedUserToken) error -} - -// RefreshedUserToken is what a renewal produced. It carries no refreshToken -// because POST /auth/v1/refresh does not issue one: the original refresh -// token stays valid and unchanged. -type RefreshedUserToken struct { - AccessToken string - ExpiresAt string -} - -// UserCredential is the read-only snapshot returned by UserClient.Credential. -type UserCredential struct { - AccessToken string - RefreshToken string - // ExpiresAt is the wire-shaped absolute Unix-seconds string, or empty - // when the expiry is unknown. - ExpiresAt string -} - -// String implements fmt.Stringer so an incidental debug print of a -// credential never puts a live token in a log line. Same rationale (and same -// [REDACTED] placeholder) as UserAccessTokenResult.String. -func (o UserCredentialOptions) String() string { - return fmt.Sprintf( - "UserCredentialOptions{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OnRefresh:%s}", - "[REDACTED]", "[REDACTED]", o.ExpiresAt, callbackPresence(o.OnRefresh), - ) -} - -// GoString implements fmt.GoStringer — %#v does not consult fmt.Stringer. -func (o UserCredentialOptions) GoString() string { - return fmt.Sprintf( - "qdmp.UserCredentialOptions{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OnRefresh:%s}", - "[REDACTED]", "[REDACTED]", o.ExpiresAt, callbackPresence(o.OnRefresh), - ) -} - -// LogValue implements slog.LogValuer — log/slog consults neither Stringer -// nor GoStringer. -func (o UserCredentialOptions) LogValue() slog.Value { - return slog.GroupValue( - slog.String("AccessToken", "[REDACTED]"), - slog.String("RefreshToken", "[REDACTED]"), - slog.String("ExpiresAt", o.ExpiresAt), - slog.String("OnRefresh", callbackPresence(o.OnRefresh)), - ) -} - -// String implements fmt.Stringer — see UserCredentialOptions.String for why. -func (t RefreshedUserToken) String() string { - return fmt.Sprintf("RefreshedUserToken{AccessToken:%q, ExpiresAt:%q}", "[REDACTED]", t.ExpiresAt) -} - -// GoString implements fmt.GoStringer — see UserCredentialOptions.GoString for why. -func (t RefreshedUserToken) GoString() string { - return fmt.Sprintf("qdmp.RefreshedUserToken{AccessToken:%q, ExpiresAt:%q}", "[REDACTED]", t.ExpiresAt) -} - -// LogValue implements slog.LogValuer — see UserCredentialOptions.LogValue for why. -func (t RefreshedUserToken) LogValue() slog.Value { - return slog.GroupValue( - slog.String("AccessToken", "[REDACTED]"), - slog.String("ExpiresAt", t.ExpiresAt), - ) -} - -// String implements fmt.Stringer — see UserCredentialOptions.String for why. -func (c UserCredential) String() string { - return fmt.Sprintf( - "UserCredential{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q}", - "[REDACTED]", "[REDACTED]", c.ExpiresAt, - ) -} - -// GoString implements fmt.GoStringer — see UserCredentialOptions.GoString for why. -func (c UserCredential) GoString() string { - return fmt.Sprintf( - "qdmp.UserCredential{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q}", - "[REDACTED]", "[REDACTED]", c.ExpiresAt, - ) -} - -// LogValue implements slog.LogValuer — see UserCredentialOptions.LogValue for why. -func (c UserCredential) LogValue() slog.Value { - return slog.GroupValue( - slog.String("AccessToken", "[REDACTED]"), - slog.String("RefreshToken", "[REDACTED]"), - slog.String("ExpiresAt", c.ExpiresAt), - ) -} - -// callbackPresence describes a callback without printing its code pointer, -// which %v would otherwise render as a bare memory address. -func callbackPresence(fn func(ctx context.Context, token RefreshedUserToken) error) string { - if fn == nil { - return "" - } - return "" -} - -// WithUserCredential derives a *UserClient that renews its own user access -// token: proactively when the known expiry is within -// tokenRefreshBufferSeconds, and reactively on an HTTP 401 carrying business -// code 10005/10006 (after which the original request is retried once). -// -// The business group methods are exactly the same as on a WithAccessToken -// client — renewal happens underneath them, in the transport. Options are -// validated on the first business call rather than here, so a missing -// AccessToken/RefreshToken surfaces from the call site as -// ErrAccessTokenRequired/ErrRefreshTokenRequired instead of from a -// constructor that cannot return an error. -func (c *Client) WithUserCredential(opts UserCredentialOptions) *UserClient { - cred := &userCredential{ - client: c, - onRefresh: opts.OnRefresh, - accessToken: opts.AccessToken, - refreshToken: opts.RefreshToken, - } - cred.setExpiresAt(opts.ExpiresAt) - - uc := c.WithAccessToken(opts.AccessToken) - uc.cred = cred - return uc -} - -// userRefreshInflight is one renewal that concurrent business calls can wait -// on instead of each issuing their own (single-flight). -type userRefreshInflight struct { - done chan struct{} - token string - gen uint64 - err error -} - -// userCredential is the mutable state behind a WithUserCredential client. -// -// generation is what makes "renew once" safe under concurrency: a business -// call remembers the generation of the token it sent, and a renewal request -// carrying a stale generation is answered with the token someone else has -// already fetched instead of issuing a second one. Without it, two calls -// that both got a 401 with the same expired token would renew twice — the -// second one throwing away a perfectly good token the first had just -// obtained. -type userCredential struct { - client *Client - onRefresh func(ctx context.Context, token RefreshedUserToken) error - - mu sync.Mutex - accessToken string - refreshToken string - expiresAt string - expiresAtUnix int64 - hasExpiry bool - generation uint64 - inflight *userRefreshInflight -} - -// setExpiresAt records a wire expiresAt string. An unparseable or negative -// value is treated exactly like an absent one — expiry unknown — so a -// malformed timestamp degrades to "renew only on 401" rather than either -// renewing on every single call or trusting a nonsense expiry. -func (c *userCredential) setExpiresAt(raw string) { - c.expiresAt = raw - if raw == "" { - c.expiresAtUnix, c.hasExpiry = 0, false - return - } - parsed, err := parseExpiresAt(raw) - if err != nil { - c.expiresAtUnix, c.hasExpiry = 0, false - return - } - c.expiresAtUnix, c.hasExpiry = parsed, true -} - -// snapshot returns an independent copy of the current credential state. -func (c *userCredential) snapshot() UserCredential { - c.mu.Lock() - defer c.mu.Unlock() - return UserCredential{ - AccessToken: c.accessToken, - RefreshToken: c.refreshToken, - ExpiresAt: c.expiresAt, - } -} - -// current reports the token to send next, the generation it belongs to, and -// whether it is close enough to expiry to renew first. All three are read -// under one lock so the generation always describes the token returned -// alongside it. -func (c *userCredential) current() (token string, gen uint64, stale bool) { - c.mu.Lock() - defer c.mu.Unlock() - token, gen = c.accessToken, c.generation - stale = c.hasExpiry && c.expiresAtUnix-tokenRefreshBufferSeconds <= time.Now().Unix() - return token, gen, stale -} - -// do runs one business request with renewal wrapped around it: renew first -// if the token is about to expire, then send; and if the response says the -// token was rejected, renew and retry exactly once. -func (c *userCredential) do(ctx context.Context, p requestParams) (json.RawMessage, error) { - token, gen, stale := c.current() - if stale { - var err error - token, gen, err = c.refresh(ctx, gen) - if err != nil { - return nil, err - } - } - - p.accessToken = token - data, err := c.client.doRequest(ctx, p) - if err == nil || !isExpiredAccessTokenError(err) { - return data, err - } - - // The token was rejected. Renew once and replay the request; whatever the - // replay returns is final — no second renewal, no second retry. - retryToken, _, refreshErr := c.refresh(ctx, gen) - if refreshErr != nil { - // The renewal itself failed (e.g. code 10008, the refresh token is - // gone too). Surface that, not the original 401: the caller has to - // re-run the authorization-code flow, and retrying the business - // request would be pointless. - return nil, refreshErr - } - p.accessToken = retryToken - return c.client.doRequest(ctx, p) -} - -// isExpiredAccessTokenError reports whether an error is specifically "the -// access token was rejected" — HTTP 401 *and* business code 10005/10006. -// Both halves matter: HTTP status is not authoritative on this API, and a -// 401 carrying any other code means something a new token would not fix. -func isExpiredAccessTokenError(err error) bool { - var apiErr *QdmpApiError - if !errors.As(err, &apiErr) { - return false - } - if apiErr.HTTPStatus != http.StatusUnauthorized { - return false - } - return apiErr.Code == codeAccessTokenRevoked || apiErr.Code == codeAccessTokenExpired -} - -// refresh renews the access token, collapsing concurrent callers into a -// single exchange, and returns the new token with its generation. -// -// seenGen is the generation of the token that prompted this call. If the -// stored generation has already moved past it, someone else's renewal has -// landed in the meantime and its token is returned as-is — this is what -// keeps a burst of concurrent 401s (or a burst of cold-start proactive -// renewals) down to one exchange. -func (c *userCredential) refresh(ctx context.Context, seenGen uint64) (string, uint64, error) { - c.mu.Lock() - if c.generation != seenGen { - token, gen := c.accessToken, c.generation - c.mu.Unlock() - return token, gen, nil - } - if existing := c.inflight; existing != nil { - c.mu.Unlock() - select { - case <-existing.done: - return existing.token, existing.gen, existing.err - case <-ctx.Done(): - return "", 0, ctx.Err() - } - } - inflight := &userRefreshInflight{done: make(chan struct{})} - c.inflight = inflight - refreshToken := c.refreshToken - c.mu.Unlock() - - token, gen, err := c.exchange(ctx, refreshToken) - - c.mu.Lock() - c.inflight = nil - c.mu.Unlock() - - inflight.token, inflight.gen, inflight.err = token, gen, err - close(inflight.done) - return token, gen, err -} - -// exchange performs the actual renewal: POST /auth/v1/refresh with the -// original refresh token and no access-token header, then applies the result -// to this credential and notifies OnRefresh. -// -// Ordering matters here. Internal state is updated *before* OnRefresh runs, -// and is deliberately not rolled back when OnRefresh fails: the server has -// already issued the new token and invalidated nothing, so discarding it -// because the caller's own persistence failed would just force another -// renewal on the next call. The OnRefresh error still fails this business -// call, so the caller does learn that persistence failed. -func (c *userCredential) exchange(ctx context.Context, refreshToken string) (string, uint64, error) { - result, err := c.client.Auth.RefreshToken(ctx, refreshToken) - if err != nil { - return "", 0, err - } - - c.mu.Lock() - c.accessToken = result.AccessToken - c.setExpiresAt(result.ExpiresAt) - // The refresh token is intentionally left alone: /auth/v1/refresh returns - // no new one, and the old one stays valid. - c.generation++ - gen := c.generation - c.mu.Unlock() - - if c.onRefresh != nil { - if err := c.onRefresh(ctx, RefreshedUserToken{ - AccessToken: result.AccessToken, - ExpiresAt: result.ExpiresAt, - }); err != nil { - return "", 0, err - } - } - return result.AccessToken, gen, nil -} diff --git a/go/usercredential_test.go b/go/usercredential_test.go deleted file mode 100644 index 52b8cca..0000000 --- a/go/usercredential_test.go +++ /dev/null @@ -1,935 +0,0 @@ -package qdmp_test - -// Contract exercised by this file (see CONTRACT.md §2 — not yet implemented): -// -// uc := client.WithUserCredential(qdmp.UserCredentialOptions{ -// AccessToken: "at_xxx", -// RefreshToken: "rt_xxx", -// ExpiresAt: "1785766657", // optional, Unix-seconds string -// OnRefresh: func(ctx context.Context, t qdmp.RefreshedUserToken) error { return nil }, -// }) -// me, err := uc.User.Me(ctx) // business method signatures unchanged -// snapshot := uc.Credential() // read-only snapshot -// -// WithUserCredential reuses the existing *UserClient business-group type -// (uc.User, uc.GenAI, ...); it only wires automatic refresh into the -// transport used underneath. WithAccessToken keeps its current -// static-token, no-auto-refresh semantics untouched. -// -// Behavior contract under test (CONTRACT.md §2.3, plus §2.4/§2.5): -// 1. Proactive refresh: expiresAt-now <= 300s triggers a refresh before the -// business call goes out. -// 2. Refresh action: POST /auth/v1/refresh, body {"refreshToken": ""}, -// authScheme noAuth (no access-token header); refreshToken never changes. -// 3. onRefresh runs after internal state is updated; if it errors, the -// whole business call fails with that error but the new token is kept -// (no rollback). -// 4. 401 + code 10005/10006 -> refresh once, retry the original request -// exactly once. -// 5. A second failure after the retry is returned as-is, no further retry. -// 6. 5xx / network errors / non-401 business errors / 401 with an -// unrelated code must never trigger a refresh. -// 7. Refresh failure itself is surfaced as-is, no business retry. -// 8. Concurrent callers collapse into a single in-flight refresh. -// 9. Credential() is a read-only, independent snapshot. -// 10. genai (x-openapi-access-token / x-openapi-app-id headers) gets the -// same auto-refresh/401-retry behavior. - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "reflect" - "strconv" - "sync" - "sync/atomic" - "testing" - "time" - - qdmp "github.com/EchoTechFE/qdmp-server-sdk/go" - "github.com/EchoTechFE/qdmp-server-sdk/go/generated" -) - -const ( - ucOriginalRefreshToken = "uc-original-refresh-token" -) - -// futureExpiresAt returns a wire-shaped (string, Unix-seconds) expiresAt -// value d in the future, computed relative to time.Now() so these tests -// never go stale as real wall-clock time advances (matching the existing -// convention in auth_test.go). -func futureExpiresAt(d time.Duration) string { - return strconv.FormatInt(time.Now().Add(d).Unix(), 10) -} - -// snapshotStringField reads a string-typed exported field by name off -// whatever uc.Credential() returns, transparently handling both a value type -// and a pointer type, without this test file needing to know (or guess) the -// snapshot's concrete type name. -func snapshotStringField(t *testing.T, snapshot any, name string) string { - t.Helper() - v := reflect.ValueOf(snapshot) - if v.Kind() == reflect.Ptr { - if v.IsNil() { - t.Fatalf("Credential() returned a nil *%s", v.Type().Elem().Name()) - } - v = v.Elem() - } - f := v.FieldByName(name) - if !f.IsValid() { - t.Fatalf("Credential() snapshot has no field %q (got type %s)", name, v.Type()) - } - return f.String() -} - -// tryTamperStringField attempts to mutate a string field on the snapshot -// in place via reflection. This only succeeds (and thus only matters) if -// Credential() handed back a pointer into the client's real internal state -// instead of an independent copy — which is exactly the bug this exists to -// catch. If Credential() returns a plain value type, CanSet() is false and -// this is a harmless no-op (the immutability property then holds by -// construction, as Go value semantics already guarantee it). -func tryTamperStringField(snapshot any, name, newValue string) { - v := reflect.ValueOf(snapshot) - if v.Kind() != reflect.Ptr || v.IsNil() { - return - } - v = v.Elem() - f := v.FieldByName(name) - if f.IsValid() && f.CanSet() && f.Kind() == reflect.String { - f.SetString(newValue) - } -} - -// --- 1/2: proactive refresh ------------------------------------------------- - -// TestUserCredential_ProactiveRefresh_WithinBuffer verifies bullet 1: an -// expiresAt sitting inside the 300s buffer must trigger a refresh BEFORE the -// business call is sent, and the business call must then use the freshly -// refreshed access token (not the stale constructor-supplied one). -func TestUserCredential_ProactiveRefresh_WithinBuffer(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/auth/v1/refresh" { - t.Fatalf("unexpected refresh request: %s %s", r.Method, r.URL.Path) - } - if got := r.Header.Get("access-token"); got != "" { - t.Fatalf("refresh request must not send an access-token header (authScheme noAuth), got %q", got) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("failed to decode refresh body: %v", err) - } - if body["refreshToken"] != ucOriginalRefreshToken { - t.Fatalf("refresh body refreshToken = %v, want %q", body["refreshToken"], ucOriginalRefreshToken) - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-1", map[string]any{ - "accessToken": "freshly-refreshed-token", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("access-token"); got != "freshly-refreshed-token" { - t.Fatalf("business call access-token header = %q, want the proactively refreshed token %q", got, "freshly-refreshed-token") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-1", map[string]any{ - "id": "user-uc-1", - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "stale-access-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(250 * time.Second), // inside the 300s buffer - }) - - me, err := uc.User.Me(context.Background()) - if err != nil { - t.Fatalf("User.Me() error = %v, want nil", err) - } - if me.ID != "user-uc-1" { - t.Fatalf("User.Me() result = %+v, want ID = user-uc-1", me) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) - } -} - -// TestUserCredential_NoProactiveRefresh_OutsideBuffer is the complementary -// boundary case: an expiresAt just outside the 300s buffer must not trigger -// any refresh at all, and the business call must go out with the original -// access token. -func TestUserCredential_NoProactiveRefresh_OutsideBuffer(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: expiresAt is outside the 300s buffer") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("access-token"); got != "still-fresh-access-token" { - t.Fatalf("business call access-token header = %q, want the original token %q (no refresh should have happened)", got, "still-fresh-access-token") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-2", map[string]any{ - "id": "user-uc-2", - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "still-fresh-access-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(301 * time.Second), // just outside the 300s buffer - }) - - me, err := uc.User.Me(context.Background()) - if err != nil { - t.Fatalf("User.Me() error = %v, want nil", err) - } - if me.ID != "user-uc-2" { - t.Fatalf("User.Me() result = %+v, want ID = user-uc-2", me) - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0 (expiresAt is outside the refresh buffer)", refreshCounter.Count()) - } -} - -// TestUserCredential_ExpiresAtEmpty_SkipsProactiveRefresh verifies that an -// unset ExpiresAt ("unknown expiry") skips proactive refresh entirely: the -// very first business call must go straight to the network with the -// constructor-supplied token, relying only on 401-triggered refresh. -func TestUserCredential_ExpiresAtEmpty_SkipsProactiveRefresh(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: expiresAt is unknown, so only 401 can trigger a refresh") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-3", map[string]any{ - "id": "user-uc-3", - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "unknown-expiry-token", - RefreshToken: ucOriginalRefreshToken, - // ExpiresAt intentionally omitted. - }) - - _, err := uc.User.Me(context.Background()) - if err != nil { - t.Fatalf("User.Me() error = %v, want nil", err) - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0 (unknown expiresAt must skip proactive refresh)", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) - } -} - -// --- 3: onRefresh timing and failure semantics ------------------------------ - -// TestUserCredential_OnRefresh_CalledWithNewTokenAndCallerContext verifies -// onRefresh actually receives the refreshed accessToken/expiresAt (not the -// stale ones) and is invoked with the same context.Context the caller passed -// into the business method (not a detached context.Background()). -func TestUserCredential_OnRefresh_CalledWithNewTokenAndCallerContext(t *testing.T) { - wantExpiresAt := futureExpiresAt(time.Hour) - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-2", map[string]any{ - "accessToken": "onrefresh-new-token", - "expiresAt": wantExpiresAt, - }) - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-4", map[string]any{"id": "user-uc-4"}) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - type ctxKeyType struct{} - var ctxKey ctxKeyType - var onRefreshCalls int64 - var sawAccessToken, sawExpiresAt string - var sawCtxValue any - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "stale-access-token-2", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(200 * time.Second), - OnRefresh: func(ctx context.Context, tok qdmp.RefreshedUserToken) error { - atomic.AddInt64(&onRefreshCalls, 1) - sawAccessToken = tok.AccessToken - sawExpiresAt = tok.ExpiresAt - sawCtxValue = ctx.Value(ctxKey) - return nil - }, - }) - - ctx := context.WithValue(context.Background(), ctxKey, "caller-marker") - if _, err := uc.User.Me(ctx); err != nil { - t.Fatalf("User.Me() error = %v, want nil", err) - } - if atomic.LoadInt64(&onRefreshCalls) != 1 { - t.Fatalf("onRefresh called %d times, want exactly 1", onRefreshCalls) - } - if sawAccessToken != "onrefresh-new-token" { - t.Fatalf("onRefresh saw AccessToken = %q, want %q", sawAccessToken, "onrefresh-new-token") - } - if sawExpiresAt != wantExpiresAt { - t.Fatalf("onRefresh saw ExpiresAt = %q, want %q", sawExpiresAt, wantExpiresAt) - } - if sawCtxValue != "caller-marker" { - t.Fatalf("onRefresh's ctx.Value(ctxKey) = %v, want %q (onRefresh must run with the caller's context, not a detached one)", sawCtxValue, "caller-marker") - } -} - -// TestUserCredential_OnRefreshError_FailsCallButKeepsNewToken is the most -// important onRefresh test: onRefresh returning an error must fail the -// triggering business call with that exact error (not swallow it, not -// substitute a different one), the business endpoint must never be reached -// in that same call (the whole call fails on account of onRefresh, not a -// business-layer failure), and — critically — the already-refreshed token -// must NOT be rolled back: a subsequent call, once expiresAt is safely in -// the future, must proceed straight to the business endpoint using the new -// token and must NOT trigger a second refresh. -func TestUserCredential_OnRefreshError_FailsCallButKeepsNewToken(t *testing.T) { - sentinelErr := errors.New("uc: failed to persist refreshed token") - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-3", map[string]any{ - "accessToken": "refreshed-despite-onrefresh-error", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("access-token"); got != "refreshed-despite-onrefresh-error" { - t.Fatalf("business call access-token header = %q, want the retained refreshed token %q", got, "refreshed-despite-onrefresh-error") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-5", map[string]any{"id": "user-uc-5"}) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "stale-access-token-3", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(100 * time.Second), // forces a refresh on the first call - OnRefresh: func(ctx context.Context, tok qdmp.RefreshedUserToken) error { - return sentinelErr - }, - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want onRefresh's error to fail the whole call") - } - if !errors.Is(err, sentinelErr) { - t.Fatalf("User.Me() error = %v, want errors.Is(err, sentinelErr) (onRefresh's error must propagate, not be swallowed or replaced)", err) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } - if businessCounter.Count() != 0 { - t.Fatalf("business endpoint hit %d times, want 0 (the call must fail on onRefresh's error before ever reaching the business endpoint)", businessCounter.Count()) - } - - // Second call: expiresAt is now far in the future (from the successful - // refresh above), so this must go straight to the business endpoint - // using the retained new token, without refreshing again. - me, err := uc.User.Me(context.Background()) - if err != nil { - t.Fatalf("second User.Me() error = %v, want nil", err) - } - if me.ID != "user-uc-5" { - t.Fatalf("second User.Me() result = %+v, want ID = user-uc-5", me) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times after the second call, want still exactly 1 (no rollback => no second refresh needed)", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) - } -} - -// --- 4/5: 401-triggered refresh + retry-exactly-once ------------------------ - -// TestUserCredential_401Code10006_RefreshesAndRetriesExactlyOnce verifies -// bullets 4 and 5 together, using an exact atomic hit-count assertion (not -// "at least once"): the first business call returns 401/10006, a refresh -// happens, and the retried call (using the new token) succeeds. -func TestUserCredential_401Code10006_RefreshesAndRetriesExactlyOnce(t *testing.T) { - var businessHits int64 - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-4", map[string]any{ - "accessToken": "retried-access-token", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt64(&businessHits, 1) - if n == 1 { - if got := r.Header.Get("access-token"); got != "expired-access-token" { - t.Fatalf("first business call access-token header = %q, want %q", got, "expired-access-token") - } - businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401", nil) - return - } - if got := r.Header.Get("access-token"); got != "retried-access-token" { - t.Fatalf("retried business call access-token header = %q, want %q", got, "retried-access-token") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-6", map[string]any{"id": "user-uc-6"}) - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "expired-access-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), // outside the buffer: no proactive refresh - }) - - me, err := uc.User.Me(context.Background()) - if err != nil { - t.Fatalf("User.Me() error = %v, want nil (must succeed after the automatic 401 retry)", err) - } - if me.ID != "user-uc-6" { - t.Fatalf("User.Me() result = %+v, want ID = user-uc-6", me) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } - if got := atomic.LoadInt64(&businessHits); got != 2 { - t.Fatalf("business endpoint hit %d times, want exactly 2 (original + exactly one retry)", got) - } -} - -// TestUserCredential_401StillFailsAfterRetry_NoSecondRefresh verifies bullet -// 5's "give up after one retry" half: if the retried call still fails, the -// error is returned as-is and there must be no second refresh/retry attempt -// (which would show up as unbounded growth in either hit counter). -func TestUserCredential_401StillFailsAfterRetry_NoSecondRefresh(t *testing.T) { - var businessHits int64 - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-5", map[string]any{ - "accessToken": "still-bad-access-token", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&businessHits, 1) - businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401-again", nil) - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "expired-access-token-2", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - me, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want the retried call's 401/10006 to surface once the retry also fails") - } - if me != nil { - t.Fatalf("User.Me() result = %+v, want nil on failure", me) - } - apiErr, ok := err.(*qdmp.QdmpApiError) - if !ok { - t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) - } - if apiErr.Code != "10006" { - t.Fatalf("apiErr.Code = %q, want %q", apiErr.Code, "10006") - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1 (no second refresh attempt after the retry also failed)", refreshCounter.Count()) - } - if got := atomic.LoadInt64(&businessHits); got != 2 { - t.Fatalf("business endpoint hit %d times, want exactly 2 (original + exactly one retry, then give up)", got) - } -} - -// --- 6/7: non-retryable failures must never refresh ------------------------- - -// TestUserCredential_401WrongCode_NoRefresh verifies the narrow, easy-to-get- -// wrong boundary in bullet 6: HTTP 401 alone is not sufficient to trigger a -// refresh — the business code must specifically be 10005 or 10006. A 401 -// with an unrelated code (10001) must be returned as-is, untouched. -func TestUserCredential_401WrongCode_NoRefresh(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: 401 with code=10001 is not a retryable auth failure") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusUnauthorized, float64(10001), "code 已使用或已过期", "req-uc-me-7", nil) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "some-access-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want the 401/10001 error surfaced as-is") - } - apiErr, ok := err.(*qdmp.QdmpApiError) - if !ok { - t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) - } - if apiErr.Code != "10001" { - t.Fatalf("apiErr.Code = %q, want %q", apiErr.Code, "10001") - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a non-retryable code)", businessCounter.Count()) - } -} - -// TestUserCredential_NonAuthBusinessError_NoRefresh verifies a plain business -// failure (HTTP 200, unrelated non-zero code) never triggers a refresh. -func TestUserCredential_NonAuthBusinessError_NoRefresh(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: this is an unrelated business error, not an auth failure") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, float64(20000), "some unrelated business failure", "req-uc-me-8", nil) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "some-access-token-2", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want the code=20000 business error surfaced") - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1", businessCounter.Count()) - } -} - -// TestUserCredential_HTTP5xx_NoRefresh verifies a transport-level 5xx (no -// business envelope at all) never triggers a refresh. -func TestUserCredential_HTTP5xx_NoRefresh(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: a 5xx is not an auth failure") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "internal error", http.StatusInternalServerError) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "some-access-token-3", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want a non-nil error for HTTP 500") - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0 (a 5xx must never trigger a refresh)", refreshCounter.Count()) - } - if businessCounter.Count() != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a 5xx)", businessCounter.Count()) - } -} - -// TestUserCredential_NetworkError_NoRefresh verifies a genuine -// transport/network failure (connection closed mid-request, no HTTP response -// at all — not even a status code) never triggers a refresh either. This is -// distinct from the 5xx case above: there is no parsed response to inspect -// at all here, so a naive "refresh on any error" implementation would be -// caught by this test where the 5xx test alone would not. -func TestUserCredential_NetworkError_NoRefresh(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: a network/transport error is not an auth failure") - }) - var businessHits int64 - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&businessHits, 1) - hj, ok := w.(http.Hijacker) - if !ok { - t.Fatalf("response writer does not support hijacking; cannot simulate a network error") - } - conn, _, err := hj.Hijack() - if err != nil { - t.Fatalf("failed to hijack connection: %v", err) - } - conn.Close() // close without ever writing a response: a genuine transport error - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "some-access-token-4", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want a non-nil error for a closed connection with no response") - } - if refreshCounter.Count() != 0 { - t.Fatalf("refresh endpoint hit %d times, want 0 (a network error must never trigger a refresh)", refreshCounter.Count()) - } - if got := atomic.LoadInt64(&businessHits); got != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry for a network error)", got) - } -} - -// TestUserCredential_RefreshItselfFails_BusinessNeverRetried verifies bullet -// 7: when /auth/v1/refresh itself fails (e.g. code 10008, expired -// refreshToken), that error must be surfaced to the caller as-is, and the -// original business request must never be retried. -func TestUserCredential_RefreshItselfFails_BusinessNeverRetried(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, float64(10008), "refreshToken 超过有效期", "req-uc-refresh-fail", nil) - }) - var businessHits int64 - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&businessHits, 1) - businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-me-401-refreshfail", nil) - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "expired-access-token-3", - RefreshToken: "an-expired-refresh-token", - ExpiresAt: futureExpiresAt(time.Hour), - }) - - _, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want the refresh failure (code=10008) to surface") - } - apiErr, ok := err.(*qdmp.QdmpApiError) - if !ok { - t.Fatalf("err type = %T, want *qdmp.QdmpApiError", err) - } - if apiErr.Code != "10008" { - t.Fatalf("apiErr.Code = %q, want %q (the refresh failure's own code, not the original 401/10006)", apiErr.Code, "10008") - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } - if got := atomic.LoadInt64(&businessHits); got != 1 { - t.Fatalf("business endpoint hit %d times, want exactly 1 (no retry: the refresh itself failed, so the business request must not be retried)", got) - } -} - -// --- 8: concurrent single-flight --------------------------------------------- - -// TestUserCredential_ConcurrentCalls_RefreshSingleFlight drives N goroutines -// to call User.Me concurrently on the same *user* credential, all starting -// from a cold, within-buffer expiresAt so every one of them independently -// "wants" a refresh. Without a single-flight guard, this would issue N -// refresh calls; the mock refresh handler sleeps briefly to widen the race -// window a naive implementation would otherwise get lucky and miss. Must -// pass under go test -race. -func TestUserCredential_ConcurrentCalls_RefreshSingleFlight(t *testing.T) { - const goroutines = 20 - var refreshHits int64 - var businessHits int64 - - mux := http.NewServeMux() - mux.HandleFunc("/auth/v1/refresh", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&refreshHits, 1) - time.Sleep(50 * time.Millisecond) // widen the race window - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-concurrent", map[string]any{ - "accessToken": "single-flighted-token", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - mux.HandleFunc("/user/v1/me", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&businessHits, 1) - if got := r.Header.Get("access-token"); got != "single-flighted-token" { - t.Errorf("business call access-token header = %q, want %q", got, "single-flighted-token") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-concurrent", map[string]any{"id": "user-uc-concurrent"}) - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "cold-access-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(50 * time.Second), // well within the 300s buffer - }) - - var wg sync.WaitGroup - errs := make([]error, goroutines) - wg.Add(goroutines) - for i := 0; i < goroutines; i++ { - go func(i int) { - defer wg.Done() - _, errs[i] = uc.User.Me(context.Background()) - }(i) - } - wg.Wait() - - for i, err := range errs { - if err != nil { - t.Fatalf("goroutine %d: User.Me() error = %v, want nil", i, err) - } - } - if got := atomic.LoadInt64(&refreshHits); got != 1 { - t.Fatalf("refresh endpoint hit %d times by %d concurrent callers, want exactly 1 (single-flight failed)", got, goroutines) - } - if got := atomic.LoadInt64(&businessHits); got != goroutines { - t.Fatalf("business endpoint hit %d times, want exactly %d (one per caller, after the shared refresh completed)", got, goroutines) - } -} - -// --- 9: read-only snapshot --------------------------------------------------- - -// TestUserCredential_Credential_ReflectsStateAndIsIndependentCopy verifies -// bullet 9 end-to-end: Credential() must report the live internal state -// (not a stale constructor-time copy) after a refresh, and mutating the -// returned value must never leak back into the client's internal state. -func TestUserCredential_Credential_ReflectsStateAndIsIndependentCopy(t *testing.T) { - wantExpiresAt := futureExpiresAt(time.Hour) - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-6", map[string]any{ - "accessToken": "credential-snapshot-token", - "expiresAt": wantExpiresAt, - }) - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("access-token"); got != "credential-snapshot-token" { - t.Fatalf("business call access-token header = %q, want %q (tampering with the snapshot must not affect internal state)", got, "credential-snapshot-token") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-me-9", map[string]any{"id": "user-uc-9"}) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "pre-refresh-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(100 * time.Second), // forces a refresh on the first call - }) - - if _, err := uc.User.Me(context.Background()); err != nil { - t.Fatalf("User.Me() error = %v, want nil", err) - } - - snap := uc.Credential() - if got := snapshotStringField(t, snap, "AccessToken"); got != "credential-snapshot-token" { - t.Fatalf("Credential().AccessToken = %q, want the refreshed token %q (Credential() must reflect live state)", got, "credential-snapshot-token") - } - if got := snapshotStringField(t, snap, "ExpiresAt"); got != wantExpiresAt { - t.Fatalf("Credential().ExpiresAt = %q, want %q", got, wantExpiresAt) - } - - // Attempt to tamper with the snapshot in place (only has an effect if - // Credential() handed back a pointer into the real internal state, - // which would be the bug). - tryTamperStringField(snap, "AccessToken", "tampered-value-should-not-stick") - - snap2 := uc.Credential() - if got := snapshotStringField(t, snap2, "AccessToken"); got != "credential-snapshot-token" { - t.Fatalf("Credential().AccessToken after tampering with a prior snapshot = %q, want it unaffected (%q) — Credential() must return an independent copy", got, "credential-snapshot-token") - } - - // And a subsequent business call must still use the real, untampered - // token (expiresAt is far in the future now, so no new refresh fires). - if _, err := uc.User.Me(context.Background()); err != nil { - t.Fatalf("second User.Me() error = %v, want nil", err) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } -} - -// --- local validation (§2.2): must fail before any HTTP request ------------- - -// TestUserCredential_EmptyAccessToken_LocalErrorNoRequest verifies an empty -// AccessToken is rejected locally, reusing the same sentinel already used by -// WithAccessToken("") elsewhere in this suite (see TestUserMe_MissingAccessToken_NoRequestSent), -// and that this happens before any HTTP request (refresh or business) is -// sent. -func TestUserCredential_EmptyAccessToken_LocalErrorNoRequest(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: local validation must fail first") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("business endpoint should never have been called: local validation must fail first") - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - me, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want a local validation error for an empty AccessToken") - } - if me != nil { - t.Fatalf("User.Me() result = %+v, want nil on validation failure", me) - } - if !errors.Is(err, qdmp.ErrAccessTokenRequired) { - t.Fatalf("err = %v, want errors.Is(err, qdmp.ErrAccessTokenRequired)", err) - } - if refreshCounter.Count() != 0 || businessCounter.Count() != 0 { - t.Fatalf("refresh hits = %d, business hits = %d, want 0/0 (must fail locally before any HTTP request)", refreshCounter.Count(), businessCounter.Count()) - } -} - -// TestUserCredential_MissingRefreshToken_LocalErrorNoRequest verifies an -// empty RefreshToken is also rejected locally (it is required per -// CONTRACT.md §2.2: without it, no auto-refresh is possible at all), and -// that this too happens before any HTTP request. -func TestUserCredential_MissingRefreshToken_LocalErrorNoRequest(t *testing.T) { - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("refresh endpoint should never have been called: local validation must fail first") - }) - businessCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("business endpoint should never have been called: local validation must fail first") - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.Handle("/user/v1/me", businessCounter) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "some-access-token-5", - RefreshToken: "", - ExpiresAt: futureExpiresAt(time.Hour), - }) - - me, err := uc.User.Me(context.Background()) - if err == nil { - t.Fatalf("User.Me() error = nil, want a local validation error for a missing RefreshToken") - } - if me != nil { - t.Fatalf("User.Me() result = %+v, want nil on validation failure", me) - } - if refreshCounter.Count() != 0 || businessCounter.Count() != 0 { - t.Fatalf("refresh hits = %d, business hits = %d, want 0/0 (must fail locally before any HTTP request)", refreshCounter.Count(), businessCounter.Count()) - } -} - -// --- 10: genai gets the same treatment --------------------------------------- - -// TestUserCredential_GenAI_401Retry verifies bullet 10: the genai group -// (which uses the x-openapi-access-token / x-openapi-app-id header pair -// instead of the standard scheme's access-token header) gets the exact same -// automatic-refresh-then-retry-exactly-once treatment as User.Me. -func TestUserCredential_GenAI_401Retry(t *testing.T) { - var businessHits int64 - refreshCounter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-refresh-genai", map[string]any{ - "accessToken": "genai-retried-token", - "expiresAt": futureExpiresAt(time.Hour), - }) - }) - mux := http.NewServeMux() - mux.Handle("/auth/v1/refresh", refreshCounter) - mux.HandleFunc("/genai/v1/generate", func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt64(&businessHits, 1) - if n == 1 { - if got := r.Header.Get("x-openapi-access-token"); got != "genai-expired-token" { - t.Fatalf("first genai call x-openapi-access-token header = %q, want %q", got, "genai-expired-token") - } - businessEnvelope(w, http.StatusUnauthorized, float64(10006), "access_token 超过有效期", "req-uc-genai-401", nil) - return - } - if got := r.Header.Get("x-openapi-access-token"); got != "genai-retried-token" { - t.Fatalf("retried genai call x-openapi-access-token header = %q, want %q", got, "genai-retried-token") - } - if got := r.Header.Get("x-openapi-app-id"); got != "test-app-id" { - t.Fatalf("retried genai call x-openapi-app-id header = %q, want %q", got, "test-app-id") - } - businessEnvelope(w, http.StatusOK, "0", "ok", "req-uc-genai-ok", map[string]any{"id": "genai-task-uc-1"}) - }) - srv := startServer(t, mux) - client := newTestClient(t, srv.URL) - - uc := client.WithUserCredential(qdmp.UserCredentialOptions{ - AccessToken: "genai-expired-token", - RefreshToken: ucOriginalRefreshToken, - ExpiresAt: futureExpiresAt(time.Hour), - }) - - res, err := uc.GenAI.Generate(context.Background(), generated.GenaiGenerateJSONBody{ - Model: "google/gemini-3.1-flash-image-preview", - }) - if err != nil { - t.Fatalf("GenAI.Generate() error = %v, want nil (must succeed after the automatic 401 retry)", err) - } - if res.ID != "genai-task-uc-1" { - t.Fatalf("GenAI.Generate() result = %+v, want ID = genai-task-uc-1", res) - } - if refreshCounter.Count() != 1 { - t.Fatalf("refresh endpoint hit %d times, want exactly 1", refreshCounter.Count()) - } - if got := atomic.LoadInt64(&businessHits); got != 2 { - t.Fatalf("genai endpoint hit %d times, want exactly 2 (original + exactly one retry)", got) - } -} diff --git a/go/wishspu.go b/go/wishspu.go index b94e477..2fdbde3 100644 --- a/go/wishspu.go +++ b/go/wishspu.go @@ -46,11 +46,12 @@ func (g *WishSpuGroup) Add(ctx context.Context, body generated.WishAddJSONBody) return nil, err } - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/wishspu/v1/add", - jsonBody: body, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/wishspu/v1/add", + jsonBody: body, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -69,11 +70,12 @@ func (g *WishSpuGroup) Cancel(ctx context.Context, body generated.WishCancelJSON return nil, err } - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodPost, - path: "/wishspu/v1/cancel", - jsonBody: body, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodPost, + path: "/wishspu/v1/cancel", + jsonBody: body, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err @@ -97,11 +99,12 @@ func (g *WishSpuGroup) List(ctx context.Context, params generated.WishListParams query.Set("offset", params.Offset) query.Set("limit", params.Limit) - data, err := g.uc.doRequest(ctx, requestParams{ - method: http.MethodGet, - path: "/wishspu/v1/list", - query: query, - authScheme: authSchemeStandard, + data, err := g.uc.client.doRequest(ctx, requestParams{ + method: http.MethodGet, + path: "/wishspu/v1/list", + query: query, + authScheme: authSchemeStandard, + accessToken: g.uc.accessToken, }) if err != nil { return nil, err diff --git a/java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java b/java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java deleted file mode 100644 index 2ca862a..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/AccessTokenSource.java +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.echotechfe.qdmp; - -/** - * Supplies the access token a single {@link QdmpTransport} call attaches, and decides whether an - * HTTP 401 that call ran into is worth exactly one retry with a renewed token. - * - *

Exists so that the "renew on 401, retry once" policy lives in one place -- {@link - * QdmpTransport} -- instead of being repeated in every method of every business group. A {@link - * QdmpContext} built from a plain token via {@link QdmpContext#of(String)} is backed by a source - * that never renews anything; a context handed out by {@link - * QdmpClient#withUserCredential(io.github.echotechfe.qdmp.auth.UserCredentialOptions)} is backed by - * one that renews the user-authorization credential through {@code POST /auth/v1/refresh}. - * - *

Implementations must be safe for concurrent use: several business calls can share one source - * and hit its methods from different threads at the same time, and a renewal triggered by one of - * them must not be duplicated by the others. - */ -public interface AccessTokenSource { - - /** - * Returns the token to attach to a request that is about to be sent, renewing it first if it is - * known to be at (or near) its expiry. - * - * @return the access token to send; never {@code null} - */ - String tokenForRequest(); - - /** - * Returns the currently held token without triggering any renewal. - * - * @return the access token held right now - */ - String currentAccessToken(); - - /** - * Reacts to a request that came back as an HTTP 401 carrying an access-token error code. - * - * @param attemptedAccessToken the token the failed request actually carried, so an implementation - * can tell "my token is stale" apart from "a concurrent caller already renewed it" - * @return the token the request should be retried once with, or {@code null} to propagate the - * original error without retrying - */ - String renewAfterUnauthorized(String attemptedAccessToken); -} 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 dac6823..c8489e4 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java @@ -1,8 +1,6 @@ package io.github.echotechfe.qdmp; import io.github.echotechfe.qdmp.auth.QdmpAuth; -import io.github.echotechfe.qdmp.auth.UserCredentialOptions; -import io.github.echotechfe.qdmp.auth.UserCredentialSession; import io.github.echotechfe.qdmp.groups.GenaiGroup; import io.github.echotechfe.qdmp.groups.IslandGroup; import io.github.echotechfe.qdmp.groups.MarkGroup; @@ -10,19 +8,16 @@ import io.github.echotechfe.qdmp.groups.TagGroup; import io.github.echotechfe.qdmp.groups.UserGroup; import io.github.echotechfe.qdmp.groups.WishSpuGroup; -import java.time.Clock; import okhttp3.OkHttpClient; /** - * Entry point for the qdmp Server SDK. Exposes {@link #auth()} for app/user credential management, - * {@link #withUserCredential} for a client that keeps one user-authorization credential alive on - * its own, 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 { private final QdmpAuth auth; - private final Clock clock; private final UserGroup user; private final IslandGroup island; private final SpuGroup spu; @@ -50,7 +45,6 @@ public QdmpClient(QdmpClientConfig config) { config.getAppSecret(), config.getTokenStore(), config.getClock()); - this.clock = config.getClock(); this.user = new UserGroup(transport); this.island = new IslandGroup(transport); this.spu = new SpuGroup(transport); @@ -64,23 +58,6 @@ public QdmpAuth auth() { return auth; } - /** - * Binds an already-obtained user-authorization credential to a client that keeps it alive: its - * business methods take no {@link QdmpContext}, and the SDK renews the credential ahead of a - * known expiry and once more after an HTTP 401 that reports the token invalid/expired, retrying - * the failed call. - * - *

The returned client shares this client's transport and groups; this client's own {@code - * user().me(ctx)}-style API is unaffected. - * - * @param options the credential (access token plus the refresh token to renew it with), its - * optional expiry, and an optional callback to persist each renewal - * @return a client bound to that credential - */ - public QdmpUserClient withUserCredential(UserCredentialOptions options) { - return new QdmpUserClient(this, new UserCredentialSession(auth, clock, options)); - } - public UserGroup user() { return user; } 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 36a1b69..275a489 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java @@ -9,10 +9,10 @@ */ public final class QdmpContext { - private final AccessTokenSource tokenSource; + private final String accessToken; - private QdmpContext(AccessTokenSource tokenSource) { - this.tokenSource = tokenSource; + private QdmpContext(String accessToken) { + this.accessToken = accessToken; } /** @@ -33,22 +33,7 @@ public static QdmpContext of(String accessToken) { throw new IllegalArgumentException("accessToken must not be blank"); } requireHeaderSafe(accessToken); - return new QdmpContext(new StaticAccessTokenSource(accessToken)); - } - - // The context handed to the business groups by QdmpUserClient: instead of a fixed token it - // carries - // the live user-authorization credential, so QdmpTransport can renew it (proactively, or after an - // HTTP 401) without any group method knowing about renewal at all. Package-private because - // QdmpUserClient is the only legitimate caller -- external callers go through - // QdmpClient#withUserCredential. - static QdmpContext boundTo(AccessTokenSource tokenSource) { - Objects.requireNonNull(tokenSource, "tokenSource must not be null"); - return new QdmpContext(tokenSource); - } - - AccessTokenSource tokenSource() { - return tokenSource; + return new QdmpContext(accessToken); } // Whitelists printable ASCII (0x20-0x7E) before the token ever reaches QdmpTransport's OkHttp @@ -75,11 +60,11 @@ static void requireHeaderSafe(String accessToken) { } /** - * Returns the access token this context currently carries, without triggering a renewal. + * Returns the access token this context carries. * * @return the access token */ public String getAccessToken() { - return tokenSource.currentAccessToken(); + 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 15139aa..8165324 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java @@ -5,13 +5,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.echotechfe.qdmp.errors.QdmpApiError; import io.github.echotechfe.qdmp.errors.QdmpTransportException; -import io.github.echotechfe.qdmp.generated.KnownErrorCodes; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; -import java.util.function.Supplier; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -35,10 +33,9 @@ * {@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 add a fourth responsibility: they let the context's - * {@link AccessTokenSource} renew the credential before the call and once after an HTTP 401 that - * carries {@code 10005}/{@code 10006}, retrying the request exactly once with the renewed token. - * That policy lives here, in one place, rather than in each business group's methods. + *

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 { @@ -48,15 +45,6 @@ public final class QdmpTransport { // body, which readBody would buffer entirely into memory before even attempting to parse it. private static final long MAX_RESPONSE_BODY_BYTES = 10L * 1024 * 1024; - // The only two business codes that mean "this access token is no longer usable, get a new one": - // 10005 (malformed/revoked) and 10006 (past its expiry). Both are reported as a real HTTP 401, - // and both are required -- a 401 carrying any other code, or either code at any other HTTP - // status, describes a different problem and must reach the caller untouched. - private static final String ACCESS_TOKEN_INVALID_CODE = - String.valueOf(KnownErrorCodes.ACCESS_TOKEN_INVALID); - private static final String ACCESS_TOKEN_EXPIRED_CODE = - String.valueOf(KnownErrorCodes.ACCESS_TOKEN_EXPIRED); - // Generated response models have no @JsonIgnoreProperties and openapi.yaml itself flags several // `data` schemas as inferred/unverified -- the server is free to add fields we didn't predict. // Forward compatibility requires tolerating unknown JSON properties instead of throwing. @@ -134,13 +122,11 @@ public T get( } /** - * Issues a GET request on behalf of a {@link QdmpContext}, letting the context's credential renew - * itself before the call and once after an access-token 401 (see {@link - * #executeWithTokenSource}). + * Issues a GET request authenticated with the token carried by a {@link QdmpContext}. * * @param path the operation path, e.g. {@code /user/v1/me} * @param scheme which header pair to attach - * @param ctx the calling context, carrying the credential to authenticate with + * @param ctx the calling context, carrying the token to authenticate with * @param query optional query parameters; values may be scalars or {@link List}s (repeated * params) * @param dataType the response {@code data} POJO type @@ -153,13 +139,7 @@ public T get( QdmpContext ctx, Map query, Class dataType) { - HttpUrl.Builder urlBuilder = pathUrlBuilder(path); - if (query != null) { - addQueryParams(urlBuilder, query); - } - HttpUrl url = urlBuilder.build(); - return executeWithTokenSource( - ctx.tokenSource(), scheme, () -> new Request.Builder().url(url).get(), dataType); + return get(path, scheme, ctx.getAccessToken(), query, dataType); } /** @@ -183,13 +163,11 @@ public T post( } /** - * Issues a POST request on behalf of a {@link QdmpContext}, letting the context's credential - * renew itself before the call and once after an access-token 401 (see {@link - * #executeWithTokenSource}). + * Issues a POST request authenticated with the token carried by a {@link QdmpContext}. * * @param path the operation path, e.g. {@code /mark/v1/add} * @param scheme which header pair to attach - * @param ctx the calling context, carrying the credential to authenticate with + * @param ctx the calling context, carrying the token to authenticate with * @param body the request body, serialized as JSON * @param dataType the response {@code data} POJO type * @param the response {@code data} type @@ -197,52 +175,7 @@ public T post( */ public T post( String path, AuthScheme scheme, QdmpContext ctx, Object body, Class dataType) { - HttpUrl url = pathUrlBuilder(path).build(); - RequestBody requestBody = jsonBody(body); - return executeWithTokenSource( - ctx.tokenSource(), - scheme, - () -> new Request.Builder().url(url).post(requestBody), - dataType); - } - - // The single place the SDK's "renew the credential, then retry the business call exactly once" - // policy is implemented -- deliberately here rather than in each of the seven business groups, - // which would otherwise have to repeat it once per operation. The retry is attempted only for an - // HTTP 401 carrying 10005/10006 (see isRenewableUnauthorized) and only if the token source - // actually produced a different token to try; everything else -- 5xx, transport failures, - // business errors at HTTP 200, a 401 with an unrelated code -- propagates untouched. The retried - // call is executed directly, never routed back through this method, so a second failure can never - // trigger a second renewal. - private T executeWithTokenSource( - AccessTokenSource tokenSource, - AuthScheme scheme, - Supplier requestFactory, - Class dataType) { - String accessToken = tokenSource.tokenForRequest(); - try { - return execute(authorize(requestFactory.get(), scheme, accessToken), dataType); - } catch (QdmpApiError e) { - if (!isRenewableUnauthorized(e)) { - throw e; - } - String renewedAccessToken = tokenSource.renewAfterUnauthorized(accessToken); - if (renewedAccessToken == null) { - throw e; - } - return execute(authorize(requestFactory.get(), scheme, renewedAccessToken), dataType); - } - } - - private static boolean isRenewableUnauthorized(QdmpApiError error) { - return error.getHttpStatus() == 401 - && (ACCESS_TOKEN_INVALID_CODE.equals(error.getCode()) - || ACCESS_TOKEN_EXPIRED_CODE.equals(error.getCode())); - } - - private Request authorize(Request.Builder requestBuilder, AuthScheme scheme, String accessToken) { - applyAuthHeaders(requestBuilder, scheme, accessToken); - return requestBuilder.build(); + return post(path, scheme, ctx.getAccessToken(), body, dataType); } private static RequestBody jsonBody(Object body) { diff --git a/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java b/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java deleted file mode 100644 index 54241aa..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpUserClient.java +++ /dev/null @@ -1,381 +0,0 @@ -package io.github.echotechfe.qdmp; - -import io.github.echotechfe.qdmp.auth.UserCredentialOptions; -import io.github.echotechfe.qdmp.auth.UserCredentialSession; -import io.github.echotechfe.qdmp.auth.UserCredentialSnapshot; -import io.github.echotechfe.qdmp.generated.GenaiDetail200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.GenaiGenerate200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.GenaiGenerateRequest; -import io.github.echotechfe.qdmp.generated.IslandDetail200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.MarkAdd200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.MarkAddRequest; -import io.github.echotechfe.qdmp.generated.MarkDetail200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.MarkList200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.MarkSearch200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.SpuDetail200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.SpuSearch200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.TagDetail200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.TagSearch200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.UserMe200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.WishAdd200ResponseAllOfData; -import io.github.echotechfe.qdmp.generated.WishAddRequest; -import io.github.echotechfe.qdmp.generated.WishCancelRequest; -import io.github.echotechfe.qdmp.generated.WishList200ResponseAllOfData; -import io.github.echotechfe.qdmp.groups.GenaiGroup; -import io.github.echotechfe.qdmp.groups.IslandGroup; -import io.github.echotechfe.qdmp.groups.MarkGroup; -import io.github.echotechfe.qdmp.groups.MarkSearchParams; -import io.github.echotechfe.qdmp.groups.SpuGroup; -import io.github.echotechfe.qdmp.groups.SpuSearchParams; -import io.github.echotechfe.qdmp.groups.TagGroup; -import io.github.echotechfe.qdmp.groups.TagSearchParams; -import io.github.echotechfe.qdmp.groups.UserGroup; -import io.github.echotechfe.qdmp.groups.WishListParams; -import io.github.echotechfe.qdmp.groups.WishSpuGroup; - -/** - * A client bound to one user-authorization credential, obtained from {@link - * QdmpClient#withUserCredential(UserCredentialOptions)}. - * - *

Its business methods take no {@link QdmpContext}: the credential is held by the client, which - * renews it 300 seconds ahead of a known expiry and again -- exactly once, then retrying the call - * -- whenever a call comes back as an HTTP 401 with business code {@code 10005}/{@code 10006}. The - * root {@link QdmpClient}'s own groups are unaffected and still take a per-call context. - * - *

Every accessor returns a thin binding over the corresponding group on the root client, so - * there is exactly one implementation of each operation and exactly one implementation of the - * renewal policy (in {@link QdmpTransport}) behind both entry points. - */ -public final class QdmpUserClient { - - private final UserCredentialSession session; - private final BoundUserGroup user; - private final BoundIslandGroup island; - private final BoundSpuGroup spu; - private final BoundTagGroup tag; - private final BoundMarkGroup mark; - private final BoundWishSpuGroup wishspu; - private final BoundGenaiGroup genai; - - QdmpUserClient(QdmpClient client, UserCredentialSession session) { - this.session = session; - QdmpContext ctx = QdmpContext.boundTo(session); - this.user = new BoundUserGroup(client.user(), ctx); - this.island = new BoundIslandGroup(client.island(), ctx); - this.spu = new BoundSpuGroup(client.spu(), ctx); - this.tag = new BoundTagGroup(client.tag(), ctx); - this.mark = new BoundMarkGroup(client.mark(), ctx); - this.wishspu = new BoundWishSpuGroup(client.wishspu(), ctx); - this.genai = new BoundGenaiGroup(client.genai(), ctx); - } - - /** - * Returns the credential as it stands right now, for a caller that wants to persist it (e.g. - * before the process exits) after the SDK has renewed it. - * - * @return an immutable snapshot; a later renewal replaces it rather than mutating it, so a - * snapshot taken earlier keeps reporting the token that was current when it was taken - */ - public UserCredentialSnapshot credential() { - return session.snapshot(); - } - - /** - * Returns the {@code user} group, bound to this client's credential. - * - * @return the bound {@code user} group - */ - public BoundUserGroup user() { - return user; - } - - /** - * Returns the {@code island} group, bound to this client's credential. - * - * @return the bound {@code island} group - */ - public BoundIslandGroup island() { - return island; - } - - /** - * Returns the {@code spu} group, bound to this client's credential. - * - * @return the bound {@code spu} group - */ - public BoundSpuGroup spu() { - return spu; - } - - /** - * Returns the {@code tag} group, bound to this client's credential. - * - * @return the bound {@code tag} group - */ - public BoundTagGroup tag() { - return tag; - } - - /** - * Returns the {@code mark} group, bound to this client's credential. - * - * @return the bound {@code mark} group - */ - public BoundMarkGroup mark() { - return mark; - } - - /** - * Returns the {@code wishspu} group, bound to this client's credential. - * - * @return the bound {@code wishspu} group - */ - public BoundWishSpuGroup wishspu() { - return wishspu; - } - - /** - * Returns the {@code genai} group, bound to this client's credential. - * - * @return the bound {@code genai} group - */ - public BoundGenaiGroup genai() { - return genai; - } - - /** The {@code user} group with this client's credential supplied for you. */ - public static final class BoundUserGroup { - - private final UserGroup delegate; - private final QdmpContext ctx; - - private BoundUserGroup(UserGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Fetches the authorized user's profile. - * - * @return the user's profile - */ - public UserMe200ResponseAllOfData me() { - return delegate.me(ctx); - } - } - - /** The {@code island} group with this client's credential supplied for you. */ - public static final class BoundIslandGroup { - - private final IslandGroup delegate; - private final QdmpContext ctx; - - private BoundIslandGroup(IslandGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Fetches an island's details. - * - * @param id the island ID - * @return the island details - */ - public IslandDetail200ResponseAllOfData detail(String id) { - return delegate.detail(ctx, id); - } - } - - /** The {@code spu} group with this client's credential supplied for you. */ - public static final class BoundSpuGroup { - - private final SpuGroup delegate; - private final QdmpContext ctx; - - private BoundSpuGroup(SpuGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Fetches a SPU's details. - * - * @param id the SPU ID - * @return the SPU details - */ - public SpuDetail200ResponseAllOfData detail(String id) { - return delegate.detail(ctx, id); - } - - /** - * Searches SPUs. - * - * @param params the search filters - * @return the SPU list and total count - */ - public SpuSearch200ResponseAllOfData search(SpuSearchParams params) { - return delegate.search(ctx, params); - } - } - - /** The {@code tag} group with this client's credential supplied for you. */ - public static final class BoundTagGroup { - - private final TagGroup delegate; - private final QdmpContext ctx; - - private BoundTagGroup(TagGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Fetches a tag's details. - * - * @param id the tag ID - * @return the tag details - */ - public TagDetail200ResponseAllOfData detail(String id) { - return delegate.detail(ctx, id); - } - - /** - * Searches tags. - * - * @param params the search filters - * @return the tag list and total count - */ - public TagSearch200ResponseAllOfData search(TagSearchParams params) { - return delegate.search(ctx, params); - } - } - - /** The {@code mark} group with this client's credential supplied for you. */ - public static final class BoundMarkGroup { - - private final MarkGroup delegate; - private final QdmpContext ctx; - - private BoundMarkGroup(MarkGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Adds a mark (optionally with a rating) for a SPU. - * - * @param request the SPU to mark and optional rating - * @return the created mark's ID - */ - public MarkAdd200ResponseAllOfData add(MarkAddRequest request) { - return delegate.add(ctx, request); - } - - /** - * Lists the user's marks, paginated. - * - * @param limit page size (max 100) - * @param offset page offset (max 100) - * @return the mark list and total count - */ - public MarkList200ResponseAllOfData list(String limit, String offset) { - return delegate.list(ctx, limit, offset); - } - - /** - * Searches the user's marks by category, paginated. - * - * @param params the search filters - * @return the mark list and total count - */ - public MarkSearch200ResponseAllOfData search(MarkSearchParams params) { - return delegate.search(ctx, params); - } - - /** - * Fetches a single mark's details, including its paginated history. - * - * @param id the mark spu info ID - * @param limit history page size (max 100) - * @param offset history page offset (max 100) - * @return the mark details - */ - public MarkDetail200ResponseAllOfData detail(String id, String limit, String offset) { - return delegate.detail(ctx, id, limit, offset); - } - } - - /** The {@code wishspu} group with this client's credential supplied for you. */ - public static final class BoundWishSpuGroup { - - private final WishSpuGroup delegate; - private final QdmpContext ctx; - - private BoundWishSpuGroup(WishSpuGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Batch-adds SPUs to the user's wish list. - * - * @param request the target IDs and type - * @return the number of successful additions - */ - public WishAdd200ResponseAllOfData add(WishAddRequest request) { - return delegate.add(ctx, request); - } - - /** - * Batch-removes SPUs from the user's wish list. - * - * @param request the target IDs and type - * @return the number of successful removals - */ - public WishAdd200ResponseAllOfData cancel(WishCancelRequest request) { - return delegate.cancel(ctx, request); - } - - /** - * Lists the user's wish list, paginated. - * - * @param params the list filters - * @return the wish list and total count - */ - public WishList200ResponseAllOfData list(WishListParams params) { - return delegate.list(ctx, params); - } - } - - /** The {@code genai} group with this client's credential supplied for you. */ - public static final class BoundGenaiGroup { - - private final GenaiGroup delegate; - private final QdmpContext ctx; - - private BoundGenaiGroup(GenaiGroup delegate, QdmpContext ctx) { - this.delegate = delegate; - this.ctx = ctx; - } - - /** - * Creates an asynchronous GenAI generation task. - * - * @param request the model, conversation contents, and optional generation settings - * @return the created task's ID - */ - public GenaiGenerate200ResponseAllOfData generate(GenaiGenerateRequest request) { - return delegate.generate(ctx, request); - } - - /** - * Queries the status/result of an asynchronous GenAI generation task. - * - * @param id the task ID returned by {@link #generate} - * @return the task's current status and, once completed, its result - */ - public GenaiDetail200ResponseAllOfData detail(String id) { - return delegate.detail(ctx, id); - } - } -} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java b/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java deleted file mode 100644 index 5d87d69..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/StaticAccessTokenSource.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.echotechfe.qdmp; - -/** - * The {@link AccessTokenSource} behind a {@link QdmpContext#of(String)} context: one fixed token, - * validated once at construction, that the SDK can neither renew nor replace. An HTTP 401 on this - * path is therefore surfaced to the caller as-is -- there is nothing to retry with. - */ -final class StaticAccessTokenSource implements AccessTokenSource { - - private final String accessToken; - - StaticAccessTokenSource(String accessToken) { - this.accessToken = accessToken; - } - - @Override - public String tokenForRequest() { - return accessToken; - } - - @Override - public String currentAccessToken() { - return accessToken; - } - - @Override - public String renewAfterUnauthorized(String attemptedAccessToken) { - return null; - } -} 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 4aee6e8..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 @@ -23,16 +23,13 @@ *

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, or hand them to {@link - * io.github.echotechfe.qdmp.QdmpClient#withUserCredential} to have the SDK keep one alive for the - * duration of a request. + * persist these themselves and decide when to renew one, calling {@link #refreshToken} as needed. */ public final class QdmpAuth { /** - * Proactive renewal buffer, in seconds: a credential is renewed once fewer than this many seconds - * remain before its reported expiry, rather than waiting for it to actually expire. Shared with - * {@link UserCredentialSession}, which applies the same buffer to user-authorization credentials. + * 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. */ static final long REFRESH_BUFFER_SECONDS = 300; diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java deleted file mode 100644 index 45a8ef9..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/RefreshedUserToken.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.echotechfe.qdmp.auth; - -/** - * What a {@link UserCredentialOptions#getOnRefresh()} callback receives after the SDK has renewed a - * user-authorization credential: the new access token and its new expiry, and nothing else. - * - *

There is deliberately no refresh token here. {@code POST /auth/v1/refresh} returns only {@code - * accessToken}/{@code expiresAt}; the refresh token the caller originally supplied stays valid and - * unchanged, so a callback persisting this back to storage should leave its stored refresh token - * alone. - * - *

{@link #toString()} omits the token value, so logging the callback argument cannot leak a live - * credential. - */ -public final class RefreshedUserToken { - - private final String accessToken; - private final long expiresAtEpochSeconds; - - /** - * Creates a new renewal notification. - * - * @param accessToken the newly issued user access token - * @param expiresAtEpochSeconds the absolute expiry of {@code accessToken}, as Unix seconds (not - * milliseconds, and not a relative "seconds remaining" duration) - */ - public RefreshedUserToken(String accessToken, long expiresAtEpochSeconds) { - this.accessToken = accessToken; - this.expiresAtEpochSeconds = expiresAtEpochSeconds; - } - - /** - * Returns the newly issued user access token. - * - * @return the access token - */ - public String getAccessToken() { - return accessToken; - } - - /** - * Returns the absolute expiry of the new access token, as Unix seconds. - * - * @return the expiry, in epoch seconds - */ - public long getExpiresAtEpochSeconds() { - return expiresAtEpochSeconds; - } - - @Override - public String toString() { - return "RefreshedUserToken{accessToken=[REDACTED], expiresAtEpochSeconds=" - + expiresAtEpochSeconds - + "}"; - } -} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java deleted file mode 100644 index 9c83024..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialOptions.java +++ /dev/null @@ -1,170 +0,0 @@ -package io.github.echotechfe.qdmp.auth; - -import io.github.echotechfe.qdmp.errors.QdmpValidationError; -import java.util.OptionalLong; -import java.util.function.Consumer; - -/** - * The user-authorization credential handed to {@code QdmpClient.withUserCredential(...)}, together - * with what the SDK needs in order to keep it alive: the refresh token to renew it with, optionally - * the expiry it should renew ahead of, and optionally a callback to persist each renewal. - * - *

{@link Builder#build()} is the single gate: a missing/empty access or refresh token fails - * right there with a {@link QdmpValidationError}, before any client is created and long before any - * HTTP request could be attempted. The refresh token is mandatory precisely because without it - * there is nothing to renew with -- a caller that only has a static token should use {@code - * QdmpContext.of(token)} against the root client instead. - * - *

{@link #toString()} omits both token values, so logging the options cannot leak a live - * credential. - */ -public final class UserCredentialOptions { - - private final String accessToken; - private final String refreshToken; - private final Long expiresAtEpochSeconds; - private final Consumer onRefresh; - - private UserCredentialOptions(Builder builder) { - this.accessToken = builder.accessToken; - this.refreshToken = builder.refreshToken; - this.expiresAtEpochSeconds = builder.expiresAtEpochSeconds; - this.onRefresh = builder.onRefresh; - } - - /** - * Starts building a set of options. - * - * @return a new builder - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Returns the user access token to start from. - * - * @return the access token - */ - public String getAccessToken() { - return accessToken; - } - - /** - * Returns the refresh token used to renew the access token. - * - * @return the refresh token - */ - public String getRefreshToken() { - return refreshToken; - } - - /** - * Returns the absolute expiry of the supplied access token, as Unix seconds. - * - * @return the expiry in epoch seconds, or {@link OptionalLong#empty()} if the caller did not - * supply one -- in which case there is no proactive renewal and the credential is only - * renewed reactively, after an HTTP 401 - */ - public OptionalLong getExpiresAtEpochSeconds() { - return expiresAtEpochSeconds == null - ? OptionalLong.empty() - : OptionalLong.of(expiresAtEpochSeconds); - } - - /** - * Returns the callback invoked after every successful renewal. - * - * @return the callback, or {@code null} if none was supplied - */ - public Consumer getOnRefresh() { - return onRefresh; - } - - @Override - public String toString() { - return "UserCredentialOptions{accessToken=[REDACTED], refreshToken=[REDACTED]," - + " expiresAtEpochSeconds=" - + expiresAtEpochSeconds - + ", onRefresh=" - + (onRefresh == null ? "none" : "set") - + "}"; - } - - /** Builder for {@link UserCredentialOptions}. */ - public static final class Builder { - - private String accessToken; - private String refreshToken; - private Long expiresAtEpochSeconds; - private Consumer onRefresh; - - private Builder() {} - - /** - * Sets the user access token to start from. - * - * @param accessToken the access token; required and must be non-empty - * @return this builder - */ - public Builder accessToken(String accessToken) { - this.accessToken = accessToken; - return this; - } - - /** - * Sets the refresh token the SDK renews the access token with. - * - * @param refreshToken the refresh token; required and must be non-empty - * @return this builder - */ - public Builder refreshToken(String refreshToken) { - this.refreshToken = refreshToken; - return this; - } - - /** - * Sets the absolute expiry of the supplied access token. - * - * @param expiresAtEpochSeconds the expiry as Unix seconds (not milliseconds, and not a relative - * "seconds remaining" duration); leave unset if it is unknown - * @return this builder - */ - public Builder expiresAt(long expiresAtEpochSeconds) { - this.expiresAtEpochSeconds = expiresAtEpochSeconds; - return this; - } - - /** - * Sets the callback invoked after every successful renewal, so the caller can persist the new - * access token and expiry. - * - * @param onRefresh the callback; a {@link RuntimeException} it throws propagates to the caller - * of the business method that triggered the renewal - * @return this builder - */ - public Builder onRefresh(Consumer onRefresh) { - this.onRefresh = onRefresh; - return this; - } - - /** - * Validates and builds the options. - * - * @return the built options - * @throws QdmpValidationError if the access token or the refresh token is {@code null} or empty - */ - public UserCredentialOptions build() { - if (accessToken == null || accessToken.isEmpty()) { - throw new QdmpValidationError( - "withUserCredential: \"accessToken\" is required and must be a non-empty string"); - } - if (refreshToken == null || refreshToken.isEmpty()) { - throw new QdmpValidationError( - "withUserCredential: \"refreshToken\" is required and must be a non-empty string --" - + " without it the SDK cannot renew the credential"); - } - return new UserCredentialOptions(this); - } - } -} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java deleted file mode 100644 index e0c8c67..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSession.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.github.echotechfe.qdmp.auth; - -import io.github.echotechfe.qdmp.AccessTokenSource; -import java.time.Clock; -import java.util.OptionalLong; -import java.util.function.Consumer; - -/** - * Keeps one user-authorization credential alive across calls: renews it {@link - * QdmpAuth#REFRESH_BUFFER_SECONDS} seconds ahead of a known expiry, renews it again if a call comes - * back as an HTTP 401 saying the token is invalid/expired, and hands the resulting token back to - * {@code QdmpTransport} through {@link AccessTokenSource}. - * - *

Public only because {@code QdmpUserClient} lives in a different package -- callers get one of - * these by calling {@code QdmpClient.withUserCredential(...)}, never by constructing it. - * - *

Renewals are single-flighted: every renewal happens while holding {@code renewLock}, and both - * entry points re-check the state after acquiring it, so several concurrent business calls that all - * notice the same stale token produce exactly one {@code POST /auth/v1/refresh} and exactly one - * {@code onRefresh} invocation. The refresh token itself never changes -- the endpoint does not - * return a new one -- so it is held in a final field. - */ -public final class UserCredentialSession implements AccessTokenSource { - - private final QdmpAuth auth; - private final Clock clock; - private final String refreshToken; - private final Consumer onRefresh; - private final Object renewLock = new Object(); - - private volatile UserCredentialSnapshot current; - - /** - * Creates a session around an already-obtained user-authorization credential. - * - * @param auth the auth module used to perform {@code POST /auth/v1/refresh} - * @param clock the clock used to decide whether the credential is close enough to expiry to renew - * proactively - * @param options the credential and renewal callback, already validated by {@link - * UserCredentialOptions.Builder#build()} - */ - public UserCredentialSession(QdmpAuth auth, Clock clock, UserCredentialOptions options) { - this.auth = auth; - this.clock = clock; - this.refreshToken = options.getRefreshToken(); - this.onRefresh = options.getOnRefresh(); - OptionalLong expiresAt = options.getExpiresAtEpochSeconds(); - this.current = - new UserCredentialSnapshot( - options.getAccessToken(), - this.refreshToken, - expiresAt.isPresent() ? expiresAt.getAsLong() : null); - } - - /** - * Returns an immutable view of the credential as it stands right now. - * - * @return the current snapshot; later renewals replace it rather than mutating it - */ - public UserCredentialSnapshot snapshot() { - return current; - } - - @Override - public String currentAccessToken() { - return current.getAccessToken(); - } - - @Override - public String tokenForRequest() { - UserCredentialSnapshot snapshot = current; - if (!needsProactiveRenewal(snapshot)) { - return snapshot.getAccessToken(); - } - synchronized (renewLock) { - // Re-check under the lock: while this caller was waiting, another one may have renewed - // already, in which case its result is reused instead of a second network call being made. - UserCredentialSnapshot rechecked = current; - if (!needsProactiveRenewal(rechecked)) { - return rechecked.getAccessToken(); - } - return renew().getAccessToken(); - } - } - - @Override - public String renewAfterUnauthorized(String attemptedAccessToken) { - synchronized (renewLock) { - UserCredentialSnapshot rechecked = current; - if (!rechecked.getAccessToken().equals(attemptedAccessToken)) { - // A concurrent caller already renewed after this request went out, so the 401 was about a - // token that is no longer current. Retry with theirs rather than renewing a second time. - return rechecked.getAccessToken(); - } - return renew().getAccessToken(); - } - } - - // Always called while holding renewLock. A failing refresh (10007/10008, or anything else) - // propagates untouched: the caller of the business method is the one who has to go get a fresh - // authorization code, and no business request may be attempted in the meantime. - private UserCredentialSnapshot renew() { - RefreshTokenResult result = auth.refreshToken(refreshToken); - // The refresh token is carried over unchanged: POST /auth/v1/refresh issues no new one. - UserCredentialSnapshot renewed = - new UserCredentialSnapshot( - result.getAccessToken(), refreshToken, result.getExpiresAtEpochSeconds()); - // Published before onRefresh runs, deliberately: if the callback throws (e.g. its database - // write - // fails) the business call fails with that error, but the token the server already issued is - // kept -- discarding it would waste a perfectly usable credential and force another renewal on - // the next call. - current = renewed; - if (onRefresh != null) { - onRefresh.accept( - new RefreshedUserToken(result.getAccessToken(), result.getExpiresAtEpochSeconds())); - } - return renewed; - } - - private boolean needsProactiveRenewal(UserCredentialSnapshot snapshot) { - OptionalLong expiresAt = snapshot.getExpiresAtEpochSeconds(); - if (!expiresAt.isPresent()) { - // Expiry unknown: there is nothing to renew ahead of, so the credential is only ever renewed - // reactively, when a call actually comes back 401. - return false; - } - long remaining = expiresAt.getAsLong() - clock.instant().getEpochSecond(); - return remaining <= QdmpAuth.REFRESH_BUFFER_SECONDS; - } -} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java deleted file mode 100644 index da1570b..0000000 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/UserCredentialSnapshot.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.echotechfe.qdmp.auth; - -import java.util.OptionalLong; - -/** - * An immutable point-in-time view of the user-authorization credential a {@link - * UserCredentialSession} is holding, as handed out by {@code QdmpUserClient.credential()}. - * - *

Immutable on purpose: a caller that grabs a snapshot in order to persist it must not have it - * mutate underneath them when a later call renews the credential. A renewal replaces the session's - * snapshot with a new object; every snapshot already handed out keeps describing the credential as - * it was at the moment it was taken. - * - *

{@link #toString()} omits the token value, so logging a snapshot cannot leak a live - * credential. - */ -public final class UserCredentialSnapshot { - - private final String accessToken; - private final String refreshToken; - private final Long expiresAtEpochSeconds; - - UserCredentialSnapshot(String accessToken, String refreshToken, Long expiresAtEpochSeconds) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.expiresAtEpochSeconds = expiresAtEpochSeconds; - } - - /** - * Returns the access token held at the moment this snapshot was taken. - * - * @return the access token - */ - public String getAccessToken() { - return accessToken; - } - - /** - * Returns the refresh token backing this credential. It never changes across renewals -- {@code - * POST /auth/v1/refresh} issues no new one -- but it is exposed here so that persisting a - * credential only needs this snapshot, rather than also reaching back to whatever the caller - * originally passed to {@code withUserCredential}. - * - * @return the refresh token - */ - public String getRefreshToken() { - return refreshToken; - } - - /** - * Returns the absolute expiry of that access token, as Unix seconds. - * - * @return the expiry in epoch seconds, or {@link OptionalLong#empty()} if it is unknown -- the - * caller never supplied one and no renewal has happened yet - */ - public OptionalLong getExpiresAtEpochSeconds() { - return expiresAtEpochSeconds == null - ? OptionalLong.empty() - : OptionalLong.of(expiresAtEpochSeconds); - } - - @Override - public String toString() { - return "UserCredentialSnapshot{accessToken=[REDACTED], refreshToken=[REDACTED]," - + " expiresAtEpochSeconds=" - + expiresAtEpochSeconds - + "}"; - } -} 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 444c897..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 @@ -16,10 +16,10 @@ * {@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 UserAccessTokenResult} + * 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()}. @@ -55,12 +55,12 @@ void getUserAccessToken_returnsUserAccessTokenResult_withMatchingGetters() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - UserAccessTokenResult session = client.auth().getUserAccessToken("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 @@ -76,19 +76,19 @@ void getUserAccessToken_toString_doesNotLeakAccessTokenOrRefreshToken() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - UserAccessTokenResult session = client.auth().getUserAccessToken("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 getUserAccessToken_toString_doesNotLeakRawCrLfInOpenId() { @@ -106,11 +106,11 @@ void getUserAccessToken_toString_doesNotLeakRawCrLfInOpenId() { + "\"}}")); QdmpClient client = TestClients.create(server); - UserAccessTokenResult session = client.auth().getUserAccessToken("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/QdmpAuthUserAccessTokenAndRefreshTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java index 73fc976..49f81ad 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java @@ -42,7 +42,7 @@ void stopServer() throws IOException { } @Test - void getUserAccessToken_success_sendsAuthorizationCodeGrant_andReturnsFullSession() + void getUserAccessToken_success_sendsAuthorizationCodeGrant_andReturnsFullCredential() throws Exception { server.enqueue( new MockResponse() @@ -53,16 +53,16 @@ void getUserAccessToken_success_sendsAuthorizationCodeGrant_andReturnsFullSessio + "\"refreshToken\":\"user-refresh-token\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - UserAccessTokenResult session = client.auth().getUserAccessToken("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 @@ -211,13 +211,13 @@ void getUserAccessToken_successCode_withNullData_throwsQdmpTransportException() new MockResponse().setResponseCode(200).setBody("{\"code\":\"0\",\"message\":\"ok\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getUserAccessToken("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 @@ -231,7 +231,7 @@ void getUserAccessToken_successCode_withDataMissingAccessToken_throwsQdmpTranspo + "\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getUserAccessToken("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/QdmpUserCredentialTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java deleted file mode 100644 index 69b838a..0000000 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpUserCredentialTest.java +++ /dev/null @@ -1,703 +0,0 @@ -package io.github.echotechfe.qdmp.auth; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.echotechfe.qdmp.QdmpClient; -import io.github.echotechfe.qdmp.QdmpUserClient; -import io.github.echotechfe.qdmp.errors.QdmpApiError; -import io.github.echotechfe.qdmp.errors.QdmpValidationError; -import io.github.echotechfe.qdmp.generated.GenaiGenerateRequest; -import io.github.echotechfe.qdmp.generated.GenaiGenerateRequestContentsInner; -import io.github.echotechfe.qdmp.generated.GenaiGenerateRequestContentsInnerPartsInner; -import io.github.echotechfe.qdmp.generated.UserMe200ResponseAllOfData; -import io.github.echotechfe.qdmp.testsupport.MutableClock; -import io.github.echotechfe.qdmp.testsupport.TestClients; -import java.io.IOException; -import java.time.Instant; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * {@code qdmp.withUserCredential(UserCredentialOptions)} (CONTRACT.md §2): a user-authorization - * credential wrapper that, unlike the one-shot {@code auth().getUserAccessToken}/{@code - * auth().refreshToken} calls, keeps the access token alive across calls -- refreshing it 300s ahead - * of a known expiry, and reactively on an HTTP 401 with business code 10005/10006, retrying the - * failed business call exactly once with the new token. - */ -class QdmpUserCredentialTest { - - private static final ObjectMapper JSON = new ObjectMapper(); - private static final String REFRESH_PATH = "/auth/v1/refresh"; - private static final String ME_PATH = "/user/v1/me"; - private static final String GENAI_GENERATE_PATH = "/genai/v1/generate"; - private static final String SENTINEL_ACCESS_TOKEN = "sentinel-user-access-token-xyz"; - private static final String SENTINEL_REFRESH_TOKEN = "sentinel-user-refresh-token-abc"; - - private MockWebServer server; - - @BeforeEach - void startServer() throws IOException { - server = new MockWebServer(); - server.start(); - } - - @AfterEach - void stopServer() throws IOException { - server.shutdown(); - } - - // ---- MockResponse builders ------------------------------------------------- - - private static MockResponse refreshSuccess(String accessToken, long expiresAtEpochSeconds) { - return new MockResponse() - .setResponseCode(200) - .setBody( - "{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"accessToken\":\"" - + accessToken - + "\",\"expiresAt\":\"" - + expiresAtEpochSeconds - + "\"}}"); - } - - private static MockResponse refreshFailure(int businessCode) { - return new MockResponse() - .setResponseCode(200) - .setBody("{\"code\":" + businessCode + ",\"message\":\"refreshToken invalid\"}"); - } - - private static MockResponse meSuccess(String userId) { - return new MockResponse() - .setResponseCode(200) - .setBody("{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"id\":\"" + userId + "\"}}"); - } - - private static MockResponse http401(int businessCode) { - return new MockResponse() - .setResponseCode(401) - .setBody("{\"code\":" + businessCode + ",\"message\":\"access_token invalid\"}"); - } - - private static MockResponse businessError200(int businessCode) { - return new MockResponse() - .setResponseCode(200) - .setBody("{\"code\":" + businessCode + ",\"message\":\"business error\"}"); - } - - private static MockResponse serverError5xx() { - return new MockResponse() - .setResponseCode(500) - .setBody("{\"code\":13,\"message\":\"internal\"}"); - } - - private static MockResponse genaiSuccess(String taskId) { - return new MockResponse() - .setResponseCode(200) - .setBody("{\"code\":\"0\",\"message\":\"ok\",\"data\":{\"id\":\"" + taskId + "\"}}"); - } - - private static GenaiGenerateRequest genaiRequest() { - return new GenaiGenerateRequest() - .model("google/gemini-3.1-flash-image-preview") - .addContentsItem( - new GenaiGenerateRequestContentsInner() - .role("user") - .addPartsItem(new GenaiGenerateRequestContentsInnerPartsInner().text("hello"))); - } - - // ---- 2.3.1 proactive refresh (300s buffer, same as app credential) -------- - - @Test - void proactiveRefresh_whenExpiresAtWithinBuffer_refreshesBeforeBusinessCall() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - long almostExpired = t0.getEpochSecond() + 200; // inside the 300s buffer - server.enqueue(refreshSuccess("refreshed-token-1", t0.getEpochSecond() + 7200)); - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("refresh-token-1") - .expiresAt(almostExpired) - .build()); - - UserMe200ResponseAllOfData me = uc.user().me(); - - assertThat(me.getId()).isEqualTo("user-1"); - assertThat(server.getRequestCount()).isEqualTo(2); - RecordedRequest refreshRequest = server.takeRequest(); - assertThat(refreshRequest.getPath()).isEqualTo(REFRESH_PATH); - assertThat(refreshRequest.getHeader("access-token")).isNull(); - JsonNode refreshBody = JSON.readTree(refreshRequest.getBody().readUtf8()); - assertThat(refreshBody.get("refreshToken").asText()).isEqualTo("refresh-token-1"); - RecordedRequest meRequest = server.takeRequest(); - assertThat(meRequest.getPath()).isEqualTo(ME_PATH); - assertThat(meRequest.getHeader("access-token")).isEqualTo("refreshed-token-1"); - } - - @Test - void noProactiveRefresh_whenExpiresAtOutsideBuffer_callsBusinessDirectly() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - long farFromExpiry = t0.getEpochSecond() + 400; // outside the 300s buffer - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("still-valid-token") - .refreshToken("refresh-token-1") - .expiresAt(farFromExpiry) - .build()); - - uc.user().me(); - - assertThat(server.getRequestCount()).isEqualTo(1); - RecordedRequest request = server.takeRequest(); - assertThat(request.getPath()).isEqualTo(ME_PATH); - } - - @Test - void noExpiresAt_skipsProactiveRefresh_reliesOnlyOn401() throws Exception { - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("no-expiry-known-token") - .refreshToken("refresh-token-1") - .build()); - - uc.user().me(); - - assertThat(server.getRequestCount()).isEqualTo(1); - assertThat(server.takeRequest().getPath()).isEqualTo(ME_PATH); - } - - // ---- 2.3.2 / 2.3.3 refresh action + onRefresh callback --------------------- - - @Test - void onRefresh_isCalledWithNewAccessTokenAndExpiry_afterProactiveRefresh() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - long expiresAt = t0.getEpochSecond() + 200; - long newExpiresAt = t0.getEpochSecond() + 7200; - server.enqueue(refreshSuccess("new-token-for-callback", newExpiresAt)); - server.enqueue(meSuccess("user-1")); - AtomicReference captured = new AtomicReference<>(); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("refresh-token-1") - .expiresAt(expiresAt) - .onRefresh(captured::set) - .build()); - - uc.user().me(); - - assertThat(captured.get()).isNotNull(); - assertThat(captured.get().getAccessToken()).isEqualTo("new-token-for-callback"); - assertThat(captured.get().getExpiresAtEpochSeconds()).isEqualTo(newExpiresAt); - } - - @Test - void refreshToken_staysUnchanged_acrossMultipleRefreshes() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - server.enqueue(refreshSuccess("token-round-1", t0.getEpochSecond() + 350)); - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("original-refresh-token") - .expiresAt(t0.getEpochSecond() + 200) - .build()); - uc.user().me(); - final RecordedRequest firstRefresh = server.takeRequest(); - server.takeRequest(); // the me() call following the first refresh - - // Advance past the second token's buffer too, forcing a second proactive refresh. - clock.advanceSeconds(100); - server.enqueue(refreshSuccess("token-round-2", clock.instant().getEpochSecond() + 7200)); - server.enqueue(meSuccess("user-1")); - - uc.user().me(); - RecordedRequest secondRefresh = server.takeRequest(); - - JsonNode firstBody = JSON.readTree(firstRefresh.getBody().readUtf8()); - JsonNode secondBody = JSON.readTree(secondRefresh.getBody().readUtf8()); - assertThat(firstBody.get("refreshToken").asText()).isEqualTo("original-refresh-token"); - assertThat(secondBody.get("refreshToken").asText()).isEqualTo("original-refresh-token"); - } - - // ---- 2.3.4 / 2.3.5 401 retry, exactly once --------------------------------- - - @Test - void retry401_code10006_refreshesOnce_andRetriesBusinessCallExactlyOnce() throws Exception { - server.enqueue(http401(10006)); - server.enqueue(refreshSuccess("token-after-401", 9_999_999_999L)); - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("stale-token") - .refreshToken("refresh-token-1") - .build()); - - UserMe200ResponseAllOfData me = uc.user().me(); - - assertThat(me.getId()).isEqualTo("user-1"); - assertThat(server.getRequestCount()).isEqualTo(3); - RecordedRequest firstMe = server.takeRequest(); - assertThat(firstMe.getPath()).isEqualTo(ME_PATH); - assertThat(firstMe.getHeader("access-token")).isEqualTo("stale-token"); - RecordedRequest refresh = server.takeRequest(); - assertThat(refresh.getPath()).isEqualTo(REFRESH_PATH); - RecordedRequest retriedMe = server.takeRequest(); - assertThat(retriedMe.getPath()).isEqualTo(ME_PATH); - assertThat(retriedMe.getHeader("access-token")).isEqualTo("token-after-401"); - } - - @Test - void retry401_code10005_refreshesOnce_andRetriesBusinessCallExactlyOnce() throws Exception { - server.enqueue(http401(10005)); - server.enqueue(refreshSuccess("token-after-401-b", 9_999_999_999L)); - server.enqueue(meSuccess("user-1")); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("stale-token") - .refreshToken("refresh-token-1") - .build()); - - uc.user().me(); - - assertThat(server.getRequestCount()).isEqualTo(3); - } - - @Test - void retry401_secondFailureAfterRetry_propagatesImmediately_noSecondRefresh() throws Exception { - server.enqueue(http401(10006)); - server.enqueue(refreshSuccess("token-still-rejected", 9_999_999_999L)); - server.enqueue(http401(10006)); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("stale-token") - .refreshToken("refresh-token-1") - .build()); - - assertThatThrownBy(uc.user()::me) - .isInstanceOf(QdmpApiError.class) - .satisfies( - e -> { - QdmpApiError err = (QdmpApiError) e; - assertThat(err.getCode()).isEqualTo("10006"); - assertThat(err.getHttpStatus()).isEqualTo(401); - }); - // Exactly: first business attempt + one refresh + retried business attempt. No further - // refresh/retry loop even though the retried call failed with the same 401/10006 shape. - assertThat(server.getRequestCount()).isEqualTo(3); - } - - // ---- 2.3.6 non-retryable failures must never trigger a refresh ------------ - - @Test - void http5xx_neverTriggersRefresh() { - server.enqueue(serverError5xx()); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("some-token") - .refreshToken("refresh-token-1") - .build()); - - assertThatThrownBy(uc.user()::me).isInstanceOf(QdmpApiError.class); - - assertThat(server.getRequestCount()).isEqualTo(1); - } - - @Test - void nonAuthBusinessErrorAtHttp200_neverTriggersRefresh() { - server.enqueue(businessError200(10002)); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("some-token") - .refreshToken("refresh-token-1") - .build()); - - assertThatThrownBy(uc.user()::me) - .isInstanceOf(QdmpApiError.class) - .satisfies(e -> assertThat(((QdmpApiError) e).getCode()).isEqualTo("10002")); - assertThat(server.getRequestCount()).isEqualTo(1); - } - - @Test - void http401_withUnrelatedBusinessCode_neverTriggersRefresh() { - // 401 alone is not the refresh trigger -- only 401 + {10005,10006} is. A 401 carrying some - // other business code must be surfaced as-is. - server.enqueue(http401(10001)); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("some-token") - .refreshToken("refresh-token-1") - .build()); - - assertThatThrownBy(uc.user()::me) - .isInstanceOf(QdmpApiError.class) - .satisfies( - e -> { - QdmpApiError err = (QdmpApiError) e; - assertThat(err.getCode()).isEqualTo("10001"); - assertThat(err.getHttpStatus()).isEqualTo(401); - }); - assertThat(server.getRequestCount()).isEqualTo(1); - } - - // ---- 2.3.7 refresh itself failing ----------------------------------------- - - @Test - void refreshItselfFails_propagatesRefreshError_andNeverRetriesBusinessCall() { - server.enqueue(http401(10006)); - server.enqueue(refreshFailure(10008)); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("stale-token") - .refreshToken("dead-refresh-token") - .build()); - - assertThatThrownBy(uc.user()::me) - .isInstanceOf(QdmpApiError.class) - .satisfies(e -> assertThat(((QdmpApiError) e).getCode()).isEqualTo("10008")); - // Only the original business call plus the failed refresh -- no second business attempt. - assertThat(server.getRequestCount()).isEqualTo(2); - } - - // ---- 2.3.3 onRefresh throwing must not roll back the already-updated token ---- - - private static final class BoomException extends RuntimeException { - BoomException(String message) { - super(message); - } - } - - @Test - void onRefreshThrows_businessCallFails_butNewTokenIsPersistedForNextCall() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - server.enqueue(refreshSuccess("post-refresh-token", t0.getEpochSecond() + 7200)); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("refresh-token-1") - .expiresAt(t0.getEpochSecond() + 100) - .onRefresh( - t -> { - throw new BoomException("db write failed"); - }) - .build()); - - assertThatThrownBy(uc.user()::me).isInstanceOf(BoomException.class); - // The refresh itself must have happened (and only once); the business call must never have - // been sent because onRefresh's failure aborts the whole call. - assertThat(server.getRequestCount()).isEqualTo(1); - assertThat(server.takeRequest().getPath()).isEqualTo(REFRESH_PATH); - - // A second call, with the clock now far past the old expiry, must go straight to the - // business endpoint using the token that was already swapped in -- proving it was not rolled - // back just because onRefresh blew up. - server.enqueue(meSuccess("user-1")); - uc.user().me(); - - assertThat(server.getRequestCount()).isEqualTo(2); - RecordedRequest secondCall = server.takeRequest(); - assertThat(secondCall.getPath()).isEqualTo(ME_PATH); - assertThat(secondCall.getHeader("access-token")).isEqualTo("post-refresh-token"); - } - - // ---- 2.3.8 concurrent single-flight refresh -------------------------------- - - @Test - void concurrentBusinessCalls_refreshHappensOnlyOnce() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - int threadCount = 8; - server.enqueue( - refreshSuccess("single-flight-user-token", t0.getEpochSecond() + 7200) - .setBodyDelay(200, TimeUnit.MILLISECONDS)); - for (int i = 0; i < threadCount; i++) { - server.enqueue(meSuccess("user-1")); - } - AtomicInteger onRefreshCount = new AtomicInteger(); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("refresh-token-1") - .expiresAt(t0.getEpochSecond() + 100) - .onRefresh(t -> onRefreshCount.incrementAndGet()) - .build()); - ExecutorService pool = Executors.newFixedThreadPool(threadCount); - CountDownLatch ready = new CountDownLatch(threadCount); - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(threadCount); - List results = new CopyOnWriteArrayList<>(); - - try { - for (int i = 0; i < threadCount; i++) { - pool.submit( - () -> { - ready.countDown(); - try { - start.await(); - results.add(uc.user().me().getId()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - done.countDown(); - } - }); - } - ready.await(); - start.countDown(); - boolean finished = done.await(10, TimeUnit.SECONDS); - - assertThat(finished).as("all callers finished within timeout").isTrue(); - assertThat(results).hasSize(threadCount).containsOnly("user-1"); - assertThat(onRefreshCount.get()).as("onRefresh must fire exactly once").isEqualTo(1); - - int refreshRequestCount = 0; - int meRequestCount = 0; - for (int i = 0; i < server.getRequestCount(); i++) { - RecordedRequest request = server.takeRequest(); - if (REFRESH_PATH.equals(request.getPath())) { - refreshRequestCount++; - } else if (ME_PATH.equals(request.getPath())) { - meRequestCount++; - } - } - assertThat(refreshRequestCount) - .as( - "only a single refresh request should reach the server despite %d concurrent callers", - threadCount) - .isEqualTo(1); - assertThat(meRequestCount).isEqualTo(threadCount); - } finally { - pool.shutdownNow(); - } - } - - // ---- 2.3.9 credential() is a read-only snapshot ---------------------------- - - @Test - void credential_reflectsLatestToken_andEarlierSnapshotIsUnaffectedByLaterRefresh() { - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken("refresh-token-1") - .build()); - - var snapshotBeforeRefresh = uc.credential(); - assertThat(snapshotBeforeRefresh.getAccessToken()).isEqualTo("initial-token"); - - server.enqueue(http401(10006)); - server.enqueue(refreshSuccess("refreshed-op-token", 9_999_999_999L)); - server.enqueue(meSuccess("user-1")); - uc.user().me(); - - var snapshotAfterRefresh = uc.credential(); - assertThat(snapshotAfterRefresh.getAccessToken()).isEqualTo("refreshed-op-token"); - // The object obtained before the refresh must not have mutated in place. - assertThat(snapshotBeforeRefresh.getAccessToken()).isEqualTo("initial-token"); - } - - // ---- 2.2 local input validation -------------------------------------------- - - @Test - void missingRefreshToken_null_failsLocally_sendsNoHttpRequest() { - QdmpClient qdmp = TestClients.create(server); - - assertThatThrownBy( - () -> { - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("at-valid") - .refreshToken(null) - .build()); - uc.user().me(); - }) - .isInstanceOf(QdmpValidationError.class); - assertThat(server.getRequestCount()).isEqualTo(0); - } - - @Test - void emptyRefreshToken_failsLocally_sendsNoHttpRequest() { - QdmpClient qdmp = TestClients.create(server); - - assertThatThrownBy( - () -> { - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("at-valid") - .refreshToken("") - .build()); - uc.user().me(); - }) - .isInstanceOf(QdmpValidationError.class); - assertThat(server.getRequestCount()).isEqualTo(0); - } - - @Test - void missingAccessToken_null_failsLocally_sendsNoHttpRequest() { - QdmpClient qdmp = TestClients.create(server); - - assertThatThrownBy( - () -> { - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken(null) - .refreshToken("rt-valid") - .build()); - uc.user().me(); - }) - .isInstanceOf(QdmpValidationError.class); - assertThat(server.getRequestCount()).isEqualTo(0); - } - - @Test - void emptyAccessToken_failsLocally_sendsNoHttpRequest() { - QdmpClient qdmp = TestClients.create(server); - - assertThatThrownBy( - () -> { - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("") - .refreshToken("rt-valid") - .build()); - uc.user().me(); - }) - .isInstanceOf(QdmpValidationError.class); - assertThat(server.getRequestCount()).isEqualTo(0); - } - - // ---- 2.5 genai group: same auto-renewal, genai-specific headers ----------- - - @Test - void genai_retry401_exactlyOnce_stillUsesGenaiHeaders_notStandardHeaders() throws Exception { - server.enqueue(http401(10006)); - server.enqueue(refreshSuccess("genai-token-after-401", 9_999_999_999L)); - server.enqueue(genaiSuccess("task-1")); - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("stale-genai-token") - .refreshToken("refresh-token-1") - .build()); - - uc.genai().generate(genaiRequest()); - - assertThat(server.getRequestCount()).isEqualTo(3); - RecordedRequest firstAttempt = server.takeRequest(); - assertThat(firstAttempt.getPath()).isEqualTo(GENAI_GENERATE_PATH); - assertThat(firstAttempt.getHeader("x-openapi-access-token")).isEqualTo("stale-genai-token"); - RecordedRequest refresh = server.takeRequest(); - assertThat(refresh.getPath()).isEqualTo(REFRESH_PATH); - RecordedRequest retriedAttempt = server.takeRequest(); - assertThat(retriedAttempt.getPath()).isEqualTo(GENAI_GENERATE_PATH); - assertThat(retriedAttempt.getHeader("x-openapi-access-token")) - .isEqualTo("genai-token-after-401"); - assertThat(retriedAttempt.getHeader("access-token")).isNull(); - } - - // ---- 2.3.10 debug printing must never leak the credential ------------------ - - @Test - void userCredentialOptions_toString_doesNotLeakAccessTokenOrRefreshToken() { - UserCredentialOptions options = - UserCredentialOptions.builder() - .accessToken(SENTINEL_ACCESS_TOKEN) - .refreshToken(SENTINEL_REFRESH_TOKEN) - .build(); - - assertThat(options.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); - assertThat(options.toString()).doesNotContain(SENTINEL_REFRESH_TOKEN); - } - - @Test - void credential_toString_doesNotLeakAccessToken() { - QdmpClient qdmp = TestClients.create(server); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken(SENTINEL_ACCESS_TOKEN) - .refreshToken(SENTINEL_REFRESH_TOKEN) - .build()); - - var snapshot = uc.credential(); - - assertThat(snapshot.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); - } - - @Test - void refreshedUserToken_toString_doesNotLeakAccessToken() throws Exception { - Instant t0 = Instant.parse("2030-01-01T00:00:00Z"); - MutableClock clock = MutableClock.startingAt(t0); - server.enqueue(refreshSuccess(SENTINEL_ACCESS_TOKEN, t0.getEpochSecond() + 7200)); - server.enqueue(meSuccess("user-1")); - AtomicReference captured = new AtomicReference<>(); - QdmpClient qdmp = TestClients.create(server, null, clock); - QdmpUserClient uc = - qdmp.withUserCredential( - UserCredentialOptions.builder() - .accessToken("initial-token") - .refreshToken(SENTINEL_REFRESH_TOKEN) - .expiresAt(t0.getEpochSecond() + 100) - .onRefresh(captured::set) - .build()); - - uc.user().me(); - - assertThat(captured.get().toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); - } -} diff --git a/node/src/client.ts b/node/src/client.ts index c11eb76..aa1e462 100644 --- a/node/src/client.ts +++ b/node/src/client.ts @@ -11,8 +11,6 @@ import {UserGroup} from './groups/user.js'; import {WishSpuGroup} from './groups/wishspu.js'; import {HttpClient} from './http.js'; import {InMemoryTokenStore, type TokenStore} from './token-store.js'; -import {QdmpUserClient} from './user-client.js'; -import type {UserCredentialOptions} from './user-credential.js'; /** The qdmp OpenAPI host (see shared/openapi.yaml `servers[0]`). */ const DEFAULT_BASE_URL = 'https://openapi.qiandao.com'; @@ -99,18 +97,11 @@ export class QdmpClient { readonly wishspu: WishSpuGroup; readonly genai: GenaiGroup; - private readonly http: HttpClient; - private readonly appId: string; - private readonly qdmpVersion: string; - constructor(options: QdmpClientOptions) { const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; assertSafeBaseUrl(baseUrl); const qdmpVersion = options.qdmpVersion ?? '1.0'; const http = new HttpClient({baseUrl, dispatcher: options.dispatcher}); - this.http = http; - this.appId = options.appId; - this.qdmpVersion = qdmpVersion; this.auth = new AuthModule({ http, @@ -128,21 +119,4 @@ export class QdmpClient { this.wishspu = new WishSpuGroup(deps); this.genai = new GenaiGroup(deps); } - - /** Binds one 用户授权凭证 to a client whose business methods take no - * QdmpContext and renew the credential on their own — proactively within - * 300 seconds of expiry, and reactively on an HTTP 401 carrying business - * code 10005/10006 (renewed once, request replayed once). - * - * The groups on `this` are unaffected: they keep requiring an explicit - * ctx and never renew anything. */ - withUserCredential(options: UserCredentialOptions): QdmpUserClient { - return new QdmpUserClient({ - http: this.http, - appId: this.appId, - qdmpVersion: this.qdmpVersion, - auth: this.auth, - options, - }); - } } diff --git a/node/src/http.ts b/node/src/http.ts index b35758d..8a2f61b 100644 --- a/node/src/http.ts +++ b/node/src/http.ts @@ -12,45 +12,11 @@ import type {Dispatcher} from 'undici'; import {QdmpApiError, QdmpTransportError} from './errors.js'; -import {QDMP_ERROR_CODE} from './generated/error-codes.js'; import type {QdmpAuthScheme, QdmpHttpMethod} from './generated/route-meta.js'; -/** Supplies — and, when needed, renews — the access-token a - * credential-bound transport puts on every request it sends. Implemented by - * the 用户授权凭证 held by a QdmpUserClient; declared here because - * HttpClient is the single place that decides when a renewal is warranted. */ -export interface AccessTokenSource { - /** The access-token the next attempt should use. Implementations perform - * the proactive (pre-expiry) renewal here, so a request is never sent - * with a token that is already inside the renewal buffer. */ - currentAccessToken(): Promise; - /** Renews after an attempt was rejected as unauthorized, resolving with - * the fresh access-token. `staleAccessToken` is the token that failed, so - * an implementation can tell "my token is stale" apart from "someone else - * already renewed while my request was in flight". */ - renewAccessToken(staleAccessToken: string): Promise; -} - export interface HttpClientConfig { baseUrl: string; dispatcher?: Dispatcher; - accessTokenSource?: AccessTokenSource; -} - -/** True only for the two business errors that mean "this access-token is no - * longer usable": a real HTTP 401 carrying business code 10005 (malformed or - * revoked) or 10006 (expired). Everything else — 5xx, transport/network - * failures, any other business code, and even a 401 carrying an unrelated - * code — must be reported to the caller untouched, without renewing. */ -function isAccessTokenRejection(error: unknown): boolean { - if (!(error instanceof QdmpApiError) || error.httpStatus !== 401) { - return false; - } - const code = String(error.code); - return ( - code === String(QDMP_ERROR_CODE.ACCESS_TOKEN_INVALID) || - code === String(QDMP_ERROR_CODE.ACCESS_TOKEN_EXPIRED) - ); } /** Upper bound on how many response body bytes we'll read before giving up. @@ -207,58 +173,17 @@ function parseEnvelope(payload: unknown, httpStatus: number): T { export class HttpClient { private readonly baseUrl: string; private readonly dispatcher: Dispatcher | undefined; - private readonly accessTokenSource: AccessTokenSource | undefined; constructor(config: HttpClientConfig) { this.baseUrl = config.baseUrl; this.dispatcher = config.dispatcher; - this.accessTokenSource = config.accessTokenSource; - } - - /** Returns a sibling transport (same baseUrl/dispatcher) whose every - * request draws its access-token from `source` instead of from the - * caller-supplied `options.accessToken`, and which renews + replays once - * when the server rejects the token. Deliberately the ONE place that - * implements the renew-and-retry policy: the seventeen groups/*.ts methods - * stay free of retry logic, so "renew at most once, replay at most once" - * cannot drift between them. - * - * The transport this is called on must itself be unbound (as the root - * client's is), otherwise a renewal request would recurse into this same - * retry path. */ - withAccessTokenSource(source: AccessTokenSource): HttpClient { - return new HttpClient({ - baseUrl: this.baseUrl, - dispatcher: this.dispatcher, - accessTokenSource: source, - }); } + /** Sends the request exactly once and returns the parsed `data` — or + * throws. The SDK never retries: an expired/rejected access-token surfaces + * as a QdmpApiError (HTTP 401 + business code 10005/10006) for the caller + * to handle, e.g. by calling `auth.refreshToken()` themselves. */ async request(options: HttpRequestOptions): Promise { - const source = this.accessTokenSource; - if (!source) { - return this.send(options); - } - // Resolving the token BEFORE the first attempt is what implements the - // proactive (pre-expiry) renewal: the source renews in here, and only - // once it — including the caller's onRefresh hook — has settled does the - // business request go out. - const accessToken = await source.currentAccessToken(); - try { - return await this.send({...options, accessToken}); - } catch (error) { - if (!isAccessTokenRejection(error)) { - throw error; - } - const renewed = await source.renewAccessToken(accessToken); - // Exactly one replay: whatever this attempt throws (including another - // 401+10005/10006) propagates untouched, so no second renewal and no - // second retry can ever happen for one business call. - return this.send({...options, accessToken: renewed}); - } - } - - private async send(options: HttpRequestOptions): Promise { const url = new URL(options.path, this.baseUrl); appendQuery(url, options.query); diff --git a/node/src/index.ts b/node/src/index.ts index 8383e1a..b1d057e 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -7,13 +7,6 @@ export { QdmpValidationError, } from './errors.js'; export type {QdmpErrorCode} from './errors.js'; -export {QdmpUserClient} from './user-client.js'; -export type {ContextBound} from './user-client.js'; -export type { - RefreshedUserToken, - UserCredentialOptions, - UserCredentialSnapshot, -} from './user-credential.js'; export {InMemoryTokenStore} from './token-store.js'; export type {StoredToken, TokenStore} from './token-store.js'; export * from './types.js'; diff --git a/node/src/user-client.ts b/node/src/user-client.ts deleted file mode 100644 index 8823237..0000000 --- a/node/src/user-client.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The client returned by `qdmp.withUserCredential(...)`: the same seventeen - * business operations as the root QdmpClient, except every one of them is - * already bound to one 用户授权凭证 and therefore takes only its own - * parameters — no QdmpContext, no access-token threading by the caller. - * - * The groups themselves are reused untouched. They receive a QdmpContext - * whose accessToken is a live view of the credential (so the existing local - * header-safety validation still runs on every call) and a transport bound - * to that same credential (so the token actually sent is the one the - * credential resolves at request time, after any renewal). - */ -import type {AuthModule} from './auth.js'; -import type {GroupDeps} from './groups/base.js'; -import {GenaiGroup} from './groups/genai.js'; -import {IslandGroup} from './groups/island.js'; -import {MarkGroup} from './groups/mark.js'; -import {SpuGroup} from './groups/spu.js'; -import {TagGroup} from './groups/tag.js'; -import {UserGroup} from './groups/user.js'; -import {WishSpuGroup} from './groups/wishspu.js'; -import type {HttpClient} from './http.js'; -import type {QdmpContext} from './types.js'; -import { - UserCredential, - type UserCredentialOptions, - type UserCredentialSnapshot, -} from './user-credential.js'; - -/** A business group with the leading QdmpContext parameter stripped from - * every method — the shape those methods take on a QdmpUserClient. */ -export type ContextBound = { - [K in keyof T]: T[K] extends (ctx: QdmpContext, ...args: infer A) => infer R - ? (...args: A) => R - : never; -}; - -/** Wraps each of `group`'s methods so callers no longer pass `ctx`. Driven - * off the prototype rather than a hand-written list, so a group gaining a - * method never silently misses it here. */ -function bindContext( - group: T, - ctx: QdmpContext, -): ContextBound { - const bound: Record = {}; - const prototype = Object.getPrototypeOf(group) as object; - for (const key of Object.getOwnPropertyNames(prototype)) { - if (key === 'constructor') { - continue; - } - const method = (group as Record)[key]; - if (typeof method !== 'function') { - continue; - } - const callable = method as (...args: unknown[]) => unknown; - bound[key] = (...args: unknown[]): unknown => - callable.apply(group, [ctx, ...args]); - } - return bound as ContextBound; -} - -export interface QdmpUserClientDeps { - /** The root client's unbound transport; a credential-bound sibling is - * derived from it here. */ - http: HttpClient; - appId: string; - qdmpVersion: string; - /** Used only to perform POST /auth/v1/refresh when the credential renews. */ - auth: AuthModule; - options: UserCredentialOptions; -} - -export class QdmpUserClient { - readonly user: ContextBound; - readonly island: ContextBound; - readonly spu: ContextBound; - readonly tag: ContextBound; - readonly mark: ContextBound; - readonly wishspu: ContextBound; - readonly genai: ContextBound; - - private readonly state: UserCredential; - - constructor(deps: QdmpUserClientDeps) { - // Constructing the credential first means an invalid accessToken / - // refreshToken is reported before any group even exists, and long before - // any request could go out. - const state = new UserCredential(deps.auth, deps.options); - this.state = state; - - const groupDeps: GroupDeps = { - http: deps.http.withAccessTokenSource(state), - appId: deps.appId, - qdmpVersion: deps.qdmpVersion, - }; - // A live view rather than a snapshot: the group reads it at call time, - // so requireAccessToken() validates whatever token is current, including - // one obtained by a renewal. - const ctx: QdmpContext = { - get accessToken(): string { - return state.snapshot().accessToken; - }, - }; - - this.user = bindContext(new UserGroup(groupDeps), ctx); - this.island = bindContext(new IslandGroup(groupDeps), ctx); - this.spu = bindContext(new SpuGroup(groupDeps), ctx); - this.tag = bindContext(new TagGroup(groupDeps), ctx); - this.mark = bindContext(new MarkGroup(groupDeps), ctx); - this.wishspu = bindContext(new WishSpuGroup(groupDeps), ctx); - this.genai = bindContext(new GenaiGroup(groupDeps), ctx); - } - - /** The credential as it stands right now — read it before the process - * exits to persist the latest token. Each read returns a fresh copy. */ - get credential(): UserCredentialSnapshot { - return this.state.snapshot(); - } -} diff --git a/node/src/user-credential.ts b/node/src/user-credential.ts deleted file mode 100644 index 4d1bc02..0000000 --- a/node/src/user-credential.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * The 用户授权凭证 (AUTHORIZATION_CODE grant) held by a QdmpUserClient, plus - * its renewal policy. - * - * Two things renew it, and nothing else does: - * - proactively, when `expiresAt` is known and within - * USER_TOKEN_RENEWAL_BUFFER_SECONDS of now (checked before every business - * request, in currentAccessToken()); - * - reactively, when a business request came back HTTP 401 with business - * code 10005/10006 (decided by HttpClient, which then calls - * renewAccessToken()). - * - * Renewals are single-flight, so N concurrent business calls that all see a - * stale token trigger exactly one POST /auth/v1/refresh and all end up using - * the one access-token it returned. - */ -import type {AuthModule} from './auth.js'; -import {QdmpValidationError} from './errors.js'; -import type {AccessTokenSource} from './http.js'; -import {withRedactedInspect} from './redact.js'; - -/** Renew this many seconds before the credential's real expiresAt — same - * buffer the 应用凭证 uses. */ -const USER_TOKEN_RENEWAL_BUFFER_SECONDS = 300; - -/** What a renewal produced. POST /auth/v1/refresh returns only these two - * fields — the refreshToken is not reissued — so this is exactly what the - * caller's onRefresh hook receives. */ -export interface RefreshedUserToken { - accessToken: string; - expiresAt: string; -} - -export interface UserCredentialOptions { - /** Non-empty; rejected locally if it carries a character that cannot be - * sent as an HTTP header value. */ - accessToken: string; - /** Non-empty. Without it the credential could not be renewed at all, which - * is the entire point of withUserCredential(). */ - refreshToken: string; - /** Absolute Unix seconds, as a string. Omit it when unknown: the - * credential then skips proactive renewal and relies solely on the - * 401 + 10005/10006 signal. */ - expiresAt?: string; - /** Called after the SDK's own state has been updated with a renewed - * token, so the caller can persist it. Awaited — the business request that - * triggered the renewal is not sent until it settles — and a rejection - * fails that business call. */ - onRefresh?: (token: RefreshedUserToken) => void | Promise; -} - -/** A read-only copy of the credential's current state. Mutating it has no - * effect on the client it came from. */ -export interface UserCredentialSnapshot { - accessToken: string; - refreshToken: string; - expiresAt?: string; -} - -/** Rejects a token that cannot be put in an HTTP header verbatim. Never - * echoes the offending value into the error message. */ -function assertHeaderSafe(value: string, field: string): void { - for (let i = 0; i < value.length; i++) { - const charCode = value.charCodeAt(i); - if (charCode <= 0x1f || charCode === 0x7f) { - throw new QdmpValidationError( - `withUserCredential: "${field}" contains a character that cannot be safely sent as an HTTP header value`, - ); - } - } -} - -/** Parses the wire format (absolute Unix seconds as a decimal string). - * Anything else — including undefined — means "expiry unknown", which - * disables proactive renewal rather than failing the call. */ -function parseExpiresAtSeconds( - expiresAt: string | undefined, -): number | undefined { - if (typeof expiresAt !== 'string' || !/^\d+$/.test(expiresAt)) { - return undefined; - } - const seconds = Number(expiresAt); - return Number.isSafeInteger(seconds) ? seconds : undefined; -} - -export class UserCredential implements AccessTokenSource { - private readonly auth: AuthModule; - private readonly refreshToken: string; - private readonly onRefresh: - ((token: RefreshedUserToken) => void | Promise) | undefined; - private accessToken: string; - private expiresAt: string | undefined; - private inFlight: Promise | undefined; - - constructor(auth: AuthModule, options: UserCredentialOptions) { - if ( - typeof options?.accessToken !== 'string' || - options.accessToken.length === 0 - ) { - throw new QdmpValidationError( - 'withUserCredential: "accessToken" is required and must be a non-empty string', - ); - } - assertHeaderSafe(options.accessToken, 'accessToken'); - if ( - typeof options.refreshToken !== 'string' || - options.refreshToken.length === 0 - ) { - throw new QdmpValidationError( - 'withUserCredential: "refreshToken" is required and must be a non-empty string — without it the credential cannot be renewed', - ); - } - this.auth = auth; - this.accessToken = options.accessToken; - this.refreshToken = options.refreshToken; - this.expiresAt = options.expiresAt; - this.onRefresh = options.onRefresh; - } - - /** A fresh object every time, so a caller that mutates what it got back - * cannot corrupt the credential this client keeps using. */ - snapshot(): UserCredentialSnapshot { - const snapshot: UserCredentialSnapshot = { - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiresAt: this.expiresAt, - }; - return withRedactedInspect( - snapshot, - () => - `UserCredentialSnapshot { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(snapshot.expiresAt)} }`, - ); - } - - /** Deliberately NOT `async`: the check-and-set of `this.inFlight` inside - * renew() must run in the caller's own synchronous turn, so that business - * calls started in the same tick all join one renewal instead of each - * starting their own. */ - currentAccessToken(): Promise { - if (this.needsProactiveRenewal()) { - return this.renew(); - } - return Promise.resolve(this.accessToken); - } - - renewAccessToken(staleAccessToken: string): Promise { - // A concurrent call may have already renewed while this request was in - // flight — its result is by definition newer than the token that just - // got rejected, so use it rather than burning a second renewal. - if (this.accessToken !== staleAccessToken) { - return Promise.resolve(this.accessToken); - } - return this.renew(); - } - - private needsProactiveRenewal(): boolean { - const expiresAtSeconds = parseExpiresAtSeconds(this.expiresAt); - if (expiresAtSeconds === undefined) { - return false; - } - const nowSeconds = Math.floor(Date.now() / 1000); - return expiresAtSeconds - nowSeconds <= USER_TOKEN_RENEWAL_BUFFER_SECONDS; - } - - private renew(): Promise { - if (!this.inFlight) { - this.inFlight = this.performRenewal().finally(() => { - this.inFlight = undefined; - }); - } - return this.inFlight; - } - - private async performRenewal(): Promise { - const refreshed = await this.auth.refreshToken(this.refreshToken); - // The server does not reissue a refreshToken here, so the original one - // stays in use for every later renewal. - this.accessToken = refreshed.accessToken; - this.expiresAt = refreshed.expiresAt; - if (this.onRefresh) { - // Awaited on purpose, and after the state above was already updated: - // if persisting fails, the business call fails with that error, but a - // token the SDK has already obtained is not thrown away — the caller - // can still read it off `userClient.credential`. - await this.onRefresh({ - accessToken: refreshed.accessToken, - expiresAt: refreshed.expiresAt, - }); - } - return this.accessToken; - } -} diff --git a/node/test/user-credential.test.ts b/node/test/user-credential.test.ts deleted file mode 100644 index bef0b50..0000000 --- a/node/test/user-credential.test.ts +++ /dev/null @@ -1,915 +0,0 @@ -/** - * withUserCredential():用户授权凭证自动续期测试。 - * - * 需求见 shared/contract(本次重构新增能力):`qdmp.withUserCredential(options)` - * 返回一个绑定了某个用户授权凭证(accessToken+refreshToken[+expiresAt])的 - * client,业务调用前会在需要时自动续期(提前 300 秒续期 / 401+10005/10006 - * 触发的续期重试),业务方法本身不再需要传 ctx。 - * - * - * mock 响应严格按已实测的服务端事实构造:响应字段驼峰、`expiresAt` 是字符串、 - * `code` 是数字 0(本项目 helpers/mock-http.ts 用字符串 `'0'` 表达 - * `String(code) === '0'` 这一唯一成功判据,与既有测试套件保持一致)、 - * `POST /auth/v1/refresh` 成功响应 data 只有 accessToken+expiresAt(不返回新 - * refreshToken)、无效 access-token 是 HTTP 401 + code 10005/10006、无效 - * refreshToken 是 HTTP 200 + code 10008。 - * - * 覆盖点(对应 CONTRACT.md §2.3 行为契约 1-10 条 + §2.4 + §2.5): - * - 本地校验:缺 refreshToken / 空 accessToken / 空 refreshToken 不发任何 HTTP 请求 - * - §2.3.1 提前续期(300 秒缓冲)+ expiresAt 未传时跳过提前续期 - * - §2.3.2 续期请求形状:body={refreshToken},noAuth(不带 access-token 头) - * - §2.3.2 refreshToken 续期后保持原值不变 - * - §2.3.3 onRefresh 只收到 {accessToken,expiresAt},必须被 await,抛错时整个 - * 业务调用失败但已更新的新 token 不回滚 - * - §2.3.4/5 401+10005/10006 触发恰好一次续期 + 恰好一次重试;重试后仍失败则 - * 直接抛错,不再续期 - * - §2.3.6 HTTP 5xx / 网络错误 / 非 401 业务错误 / 401 但 code 不在 - * {10005,10006} 一律不触发续期 - * - §2.3.7 续期本身失败时原样抛给调用方,不重试业务请求 - * - §2.3.8 并发业务调用时续期只发生一次(单飞) - * - §2.3.9 credential 是只读快照,外部改它不影响内部状态 - * - §2.3.10 credential 在 util.inspect()/console.log() 路径脱敏 - * - §2.4 业务方法不带 ctx(uc.user.me()、uc.mark.add({spuId})) - * - §2.5 genai 分组同样自动续期 - */ -import assert from 'node:assert/strict'; -import {test} from 'node:test'; -import {inspect} from 'node:util'; -import type {MockAgent} from 'undici'; - -import {QdmpClient} from '../src/client.js'; -import {QdmpApiError, QdmpValidationError} from '../src/errors.js'; -import { - businessFailure, - businessSuccess, - createMockAgent, - expectFailure, - gatewayFailure, - replyWith, -} from './helpers/mock-http.js'; - -function nowSeconds(): number { - return Math.floor(Date.now() / 1000); -} - -function makeRootClient(mockAgent: MockAgent) { - return new QdmpClient({ - appId: 'app-id-1', - appSecret: 'app-secret-1', - dispatcher: mockAgent, - }); -} - -// --------------------------------------------------------------------------- -// 本地校验:不发任何 HTTP 请求 -// --------------------------------------------------------------------------- - -void test('withUserCredential: accessToken 为空字符串时本地报错,不发任何 HTTP 请求', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessSuccess({id: 'should-not-be-reached'})); - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'should-not-be-used', - expiresAt: String(nowSeconds() + 7200), - }), - ); - - const qdmp = makeRootClient(mockAgent); - - const err = await expectFailure(async () => { - const uc = qdmp.withUserCredential({ - accessToken: '', - refreshToken: 'rt-1', - }); - await uc.user.me(); - }); - - assert.ok( - err instanceof QdmpValidationError, - `accessToken 为空字符串时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, - ); - assert.equal( - mockAgent.pendingInterceptors().length, - 2, - '本地校验失败时不应发出任何 HTTP 请求,业务接口和 refresh 接口的拦截器都必须仍未被消费', - ); -}); - -void test('withUserCredential: 缺少 refreshToken 时本地报错,不发任何 HTTP 请求', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessSuccess({id: 'should-not-be-reached'})); - - const qdmp = makeRootClient(mockAgent); - - const err = await expectFailure(async () => { - // @ts-expect-error 故意不传 refreshToken,验证运行时本地校验 - const uc = qdmp.withUserCredential({accessToken: 'at-1'}); - await uc.user.me(); - }); - - assert.ok( - err instanceof QdmpValidationError, - `缺少 refreshToken 时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, - ); - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - '本地校验失败时不应发出任何 HTTP 请求', - ); -}); - -void test('withUserCredential: refreshToken 为空字符串时本地报错(没有它就无法续期,等同于静态 token)', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessSuccess({id: 'should-not-be-reached'})); - - const qdmp = makeRootClient(mockAgent); - - const err = await expectFailure(async () => { - const uc = qdmp.withUserCredential({ - accessToken: 'at-1', - refreshToken: '', - }); - await uc.user.me(); - }); - - assert.ok( - err instanceof QdmpValidationError, - `refreshToken 为空字符串时应本地抛出 QdmpValidationError,实际抛出: ${String(err)}`, - ); - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - '本地校验失败时不应发出任何 HTTP 请求', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.1 / §2.3.2:提前续期 + 续期请求本身的形状 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.1/§2.3.2: expiresAt 距过期 <=300 秒时业务调用前先续期,续期请求 body={refreshToken} 且不带 access-token 头', async t => { - t.mock.timers.enable({apis: ['Date']}); - const nowS = nowSeconds(); - const {mockAgent, pool} = createMockAgent(); - - let refreshBody: unknown; - let refreshHeaders: Record | undefined; - pool - .intercept({ - path: '/auth/v1/refresh', - method: 'POST', - body: raw => { - refreshBody = JSON.parse(raw as string); - return true; - }, - headers: h => { - refreshHeaders = h as Record; - return true; - }, - }) - .reply( - 200, - businessSuccess({ - accessToken: 'at-refreshed-1', - expiresAt: String(nowS + 7200), - }), - ); - - let businessHeaders: Record | undefined; - pool - .intercept({ - path: '/user/v1/me', - method: 'GET', - headers: h => { - businessHeaders = h as Record; - return true; - }, - }) - .reply(200, businessSuccess({id: 'u-1', nickname: 'Alice'})); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-stale-000', - refreshToken: 'rt-stable-000', - expiresAt: String(nowS + 200), // 距过期只剩 200 秒,落在 300 秒缓冲区内 - }); - - const me = await uc.user.me(); - - assert.equal(me.id, 'u-1'); - assert.deepEqual( - refreshBody, - {refreshToken: 'rt-stable-000'}, - '续期请求 body 必须只有原始 refreshToken 这一个字段', - ); - assert.equal( - refreshHeaders?.['access-token'], - undefined, - 'noAuth 方案的续期请求不应携带 access-token 头', - ); - assert.equal( - businessHeaders?.['access-token'], - 'at-refreshed-1', - '业务请求必须使用续期后的新 accessToken,而不是原来即将过期的旧值', - ); - assert.equal( - mockAgent.pendingInterceptors().length, - 0, - '续期请求和业务请求的拦截器都应当各被消费恰好一次', - ); -}); - -void test('withUserCredential §2.3.1: expiresAt 未传时跳过提前续期,首次调用直接发业务请求(refresh 端点 0 次请求)', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessSuccess({id: 'u-1'})); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-000', - refreshToken: 'rt-000', - // 不传 expiresAt - }); - - const me = await uc.user.me(); - - assert.equal(me.id, 'u-1'); - assert.equal( - mockAgent.pendingInterceptors().length, - 0, - '唯一注册的业务拦截器应当被消费;若实现误触发了续期,会因 refresh 端点没有注册拦截器而在 disableNetConnect 下报错,导致本次调用失败', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.2 / §2.3.3:refreshToken 保持不变 + onRefresh 参数/时机 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.2/§2.3.3: 续期后 refreshToken 保持原值不变,onRefresh 只收到 {accessToken,expiresAt}', async t => { - t.mock.timers.enable({apis: ['Date']}); - const nowS = nowSeconds(); - const {mockAgent, pool} = createMockAgent(); - - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'at-refreshed-2', - expiresAt: String(nowS + 7200), - }), - ); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessSuccess({id: 'u-1'})); - - const onRefreshCalls: unknown[] = []; - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-stale', - refreshToken: 'rt-must-not-change', - expiresAt: String(nowS + 100), - onRefresh: async token => { - onRefreshCalls.push(token); - }, - }); - - await uc.user.me(); - - assert.equal(onRefreshCalls.length, 1, 'onRefresh 应当恰好被调用一次'); - assert.deepEqual( - onRefreshCalls[0], - {accessToken: 'at-refreshed-2', expiresAt: String(nowS + 7200)}, - 'onRefresh 收到的参数必须只有 accessToken/expiresAt,不应包含 refreshToken(服务端 refresh 响应本来就不返回新 refreshToken)', - ); - assert.equal( - uc.credential.refreshToken, - 'rt-must-not-change', - '续期后 refreshToken 必须保持原值不变', - ); - assert.equal(uc.credential.accessToken, 'at-refreshed-2'); - assert.equal(uc.credential.expiresAt, String(nowS + 7200)); -}); - -void test('withUserCredential §2.3.3: onRefresh 必须被 await,业务请求要等 onRefresh resolve 之后才发出', async t => { - t.mock.timers.enable({apis: ['Date']}); - const nowS = nowSeconds(); - const {mockAgent, pool} = createMockAgent(); - - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'at-refreshed-3', - expiresAt: String(nowS + 7200), - }), - ); - - const order: string[] = []; - let releaseOnRefresh: () => void = () => {}; - const gate = new Promise(resolve => { - releaseOnRefresh = resolve; - }); - - pool.intercept({path: '/user/v1/me', method: 'GET'}).reply( - replyWith(200, businessSuccess({id: 'u-1'}), () => { - order.push('business-request-sent'); - }), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-stale', - refreshToken: 'rt-1', - expiresAt: String(nowS + 100), - onRefresh: async () => { - order.push('onRefresh-start'); - await gate; - order.push('onRefresh-end'); - }, - }); - - const callPromise = uc.user.me(); - - await new Promise(resolve => setTimeout(resolve, 20)); - assert.deepEqual( - order, - ['onRefresh-start'], - '业务请求不应在 onRefresh 完成之前就被发出', - ); - - releaseOnRefresh(); - await callPromise; - - assert.deepEqual( - order, - ['onRefresh-start', 'onRefresh-end', 'business-request-sent'], - 'onRefresh 必须被 await,业务请求必须严格排在 onRefresh resolve 之后', - ); -}); - -void test('withUserCredential §2.3.3: onRefresh 抛错时整个业务调用失败,但内部已更新的新 token 不回滚', async t => { - t.mock.timers.enable({apis: ['Date']}); - const nowS = nowSeconds(); - const {mockAgent, pool} = createMockAgent(); - - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'at-after-onrefresh-throw', - expiresAt: String(nowS + 7200), - }), - ); - // 故意不注册 /user/v1/me 拦截器:onRefresh 失败后业务请求本不应该被发出; - // 若实现仍然发出业务请求,disableNetConnect 会让它报出与预期不同的错误, - // 从而暴露"onRefresh 失败后仍继续发业务请求"这个 bug。 - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-stale', - refreshToken: 'rt-1', - expiresAt: String(nowS + 100), - onRefresh: () => { - throw new Error('db write failed'); - }, - }); - - const err = await expectFailure(() => uc.user.me()); - - assert.ok( - `${(err as Error)?.message ?? err}`.includes('db write failed'), - `业务调用应当以 onRefresh 抛出的错误失败,不能被吞掉,实际抛出: ${String(err)}`, - ); - assert.equal( - uc.credential.accessToken, - 'at-after-onrefresh-throw', - '即使 onRefresh 失败,已经换到手的新 accessToken 也不应回滚', - ); - assert.equal(uc.credential.expiresAt, String(nowS + 7200)); -}); - -// --------------------------------------------------------------------------- -// §2.3.4 / §2.3.5:401 续期重试 + 只重试一次 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.4/§2.3.5: 401+10006 触发恰好一次续期 + 恰好一次重试,用新 token 重试成功', async () => { - const {mockAgent, pool} = createMockAgent(); - - let businessCallCount = 0; - const capturedTokens: Array = []; - const countBusinessCall = (h: Record) => { - businessCallCount++; - capturedTokens.push(h['access-token']); - }; - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply( - replyWith( - 401, - businessFailure(10006, 'access_token 超过有效期'), - countBusinessCall, - ), - ); - - let refreshCallCount = 0; - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - replyWith( - 200, - businessSuccess({ - accessToken: 'at-after-401-retry', - expiresAt: String(nowSeconds() + 7200), - }), - () => { - refreshCallCount++; - }, - ), - ); - - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(replyWith(200, businessSuccess({id: 'u-1'}), countBusinessCall)); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-invalid', - refreshToken: 'rt-1', - // 不传 expiresAt:只靠 401 触发续期 - }); - - const me = await uc.user.me(); - - assert.equal(me.id, 'u-1'); - assert.equal(refreshCallCount, 1, '401+10006 应当恰好触发一次续期'); - assert.equal( - businessCallCount, - 2, - '业务请求应当恰好发生两次:首次失败 + 重试一次', - ); - assert.equal(capturedTokens[0], 'at-invalid'); - assert.equal( - capturedTokens[1], - 'at-after-401-retry', - '重试请求必须使用续期后的新 accessToken', - ); -}); - -void test('withUserCredential §2.3.5: 重试后仍然失败(同样是 401+10006)时直接抛出该错误,不再触发第二次续期', async () => { - const {mockAgent, pool} = createMockAgent(); - - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(401, businessFailure(10006, 'access_token 超过有效期')); - - let refreshCallCount = 0; - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - replyWith( - 200, - businessSuccess({ - accessToken: 'at-still-bad', - expiresAt: String(nowSeconds() + 7200), - }), - () => { - refreshCallCount++; - }, - ), - ); - - // 重试请求同样失败:若实现在重试失败后又发起了第二次续期,会因为 refresh - // 端点只注册了一个拦截器(已被上面的续期消费)而在 disableNetConnect 下 - // 报出与预期(10006)不同的错误,从而暴露"重试失败后仍继续续期"的 bug。 - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(401, businessFailure(10006, 'access_token 超过有效期(仍然无效)')); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-invalid', - refreshToken: 'rt-1', - }); - - const err = await expectFailure(() => uc.user.me()); - - assert.ok(err instanceof QdmpApiError); - const apiErr = err as QdmpApiError; - assert.equal(String(apiErr.code), '10006'); - assert.equal(apiErr.httpStatus, 401); - assert.equal(refreshCallCount, 1, '重试后仍失败时不应再触发第二次续期'); - assert.equal( - mockAgent.pendingInterceptors().length, - 0, - '三个拦截器(首次业务失败、续期、重试业务仍失败)应当各被消费恰好一次,不多不少', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.6:不该重试的一律不重试(也不触发续期) -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.6: HTTP 5xx 不触发续期,原样抛出错误', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(500, gatewayFailure(2, 'internal server error')); - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'should-not-be-used', - expiresAt: String(nowSeconds() + 7200), - }), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-1', - refreshToken: 'rt-1', - }); - - const err = await expectFailure(() => uc.user.me()); - - assert.ok(err instanceof QdmpApiError); - assert.equal((err as QdmpApiError).httpStatus, 500); - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - 'HTTP 5xx 不应触发续期,refresh 端点的拦截器必须仍未被消费', - ); -}); - -void test('withUserCredential §2.3.6: 网络/传输错误不触发续期,原样抛出错误', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .replyWithError(new Error('simulated network failure')); - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'should-not-be-used', - expiresAt: String(nowSeconds() + 7200), - }), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-1', - refreshToken: 'rt-1', - }); - - await expectFailure(() => uc.user.me()); - - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - '网络/传输错误不应触发续期,refresh 端点的拦截器必须仍未被消费', - ); -}); - -void test('withUserCredential §2.3.6: 非 401 的业务错误码不触发续期', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(200, businessFailure(10001, 'appId 无效')); - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'should-not-be-used', - expiresAt: String(nowSeconds() + 7200), - }), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-1', - refreshToken: 'rt-1', - }); - - const err = await expectFailure(() => uc.user.me()); - assert.ok(err instanceof QdmpApiError); - assert.equal(String((err as QdmpApiError).code), '10001'); - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - '非 401 的业务错误不应触发续期', - ); -}); - -void test('withUserCredential §2.3.6: HTTP 401 但业务 code 不是 10005/10006 时不触发续期', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(401, businessFailure(40001, '未知的 401 语义')); - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - 200, - businessSuccess({ - accessToken: 'should-not-be-used', - expiresAt: String(nowSeconds() + 7200), - }), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-1', - refreshToken: 'rt-1', - }); - - const err = await expectFailure(() => uc.user.me()); - assert.ok(err instanceof QdmpApiError); - assert.equal(String((err as QdmpApiError).code), '40001'); - assert.equal( - mockAgent.pendingInterceptors().length, - 1, - 'HTTP 401 但 code 不是 10005/10006 时不应触发续期', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.7:续期本身失败 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.7: 续期本身失败(refresh 返回 10008)时把该错误原样抛给调用方,且不重试业务请求', async () => { - const {mockAgent, pool} = createMockAgent(); - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply(401, businessFailure(10006, 'access_token 超过有效期')); - pool - .intercept({path: '/auth/v1/refresh', method: 'POST'}) - .reply(200, businessFailure(10008, 'refreshToken 超过有效期')); - // 故意不注册第二个 /user/v1/me 拦截器:续期失败后不应该发起业务重试; - // 若实现仍然重试,disableNetConnect 会让它报出与预期(10008)不同的错误。 - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-invalid', - refreshToken: 'rt-expired', - }); - - const err = await expectFailure(() => uc.user.me()); - - assert.ok(err instanceof QdmpApiError); - const apiErr = err as QdmpApiError; - assert.equal(String(apiErr.code), '10008'); - assert.equal( - mockAgent.pendingInterceptors().length, - 0, - '首次业务请求和续期请求的拦截器都应各被消费恰好一次,不应发生业务重试', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.8:并发单飞 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.8: 并发多个业务调用时续期只发生一次(单飞)', async () => { - const nowS = nowSeconds(); - const {mockAgent, pool} = createMockAgent(); - - let refreshCallCount = 0; - pool - .intercept({path: '/auth/v1/refresh', method: 'POST'}) - .reply( - replyWith( - 200, - businessSuccess({ - accessToken: 'at-single-flight', - expiresAt: String(nowS + 7200), - }), - () => { - refreshCallCount++; - }, - ), - ) - .delay(20); - - const capturedTokens: Array = []; - pool - .intercept({path: '/user/v1/me', method: 'GET'}) - .reply( - replyWith(200, businessSuccess({id: 'u-1'}), h => { - capturedTokens.push(h['access-token']); - }), - ) - .persist(); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-stale', - refreshToken: 'rt-1', - expiresAt: String(nowS + 100), // 落在续期缓冲区内,三个并发调用都会看到同样的"需要续期"状态 - }); - - const [r1, r2, r3] = await Promise.all([ - uc.user.me(), - uc.user.me(), - uc.user.me(), - ]); - - assert.equal(r1.id, 'u-1'); - assert.equal(r2.id, 'u-1'); - assert.equal(r3.id, 'u-1'); - assert.equal( - refreshCallCount, - 1, - '三个并发业务调用应当只触发一次真实的续期请求,其余调用应等待同一次续期结果', - ); - assert.equal(capturedTokens.length, 3); - for (const token of capturedTokens) { - assert.equal( - token, - 'at-single-flight', - '所有并发调用最终都应使用同一次续期换来的新 accessToken', - ); - } -}); - -// --------------------------------------------------------------------------- -// §2.3.9:credential 快照 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.9: credential 返回快照,外部修改它不影响内部状态', async () => { - const {mockAgent, pool} = createMockAgent(); - - let capturedToken: string | undefined; - pool - .intercept({ - path: '/user/v1/me', - method: 'GET', - headers: h => { - capturedToken = (h as Record)['access-token']; - return true; - }, - }) - .reply(200, businessSuccess({id: 'u-1'})) - .persist(); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-real-000', - refreshToken: 'rt-real-000', - }); - - await uc.user.me(); - assert.equal(capturedToken, 'at-real-000'); - - const snapshot = uc.credential as { - accessToken: string; - refreshToken: string; - }; - snapshot.accessToken = 'tampered-access-token'; - snapshot.refreshToken = 'tampered-refresh-token'; - - assert.equal( - uc.credential.accessToken, - 'at-real-000', - 'credential 必须是快照/不可变副本,外部改它不应污染内部状态', - ); - - await uc.user.me(); - assert.equal( - capturedToken, - 'at-real-000', - '篡改快照后再次发起业务调用,仍应使用未被污染的真实内部 accessToken', - ); -}); - -// --------------------------------------------------------------------------- -// §2.3.10:调试打印路径脱敏 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.3.10: uc.credential 的 util.inspect()/console.log() 路径必须脱敏,不暴露 accessToken/refreshToken 明文', () => { - const SENTINEL_ACCESS = 'sentinel-uc-access-do-not-leak-abc123'; - const SENTINEL_REFRESH = 'sentinel-uc-refresh-do-not-leak-abc123'; - const {mockAgent} = createMockAgent(); - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: SENTINEL_ACCESS, - refreshToken: SENTINEL_REFRESH, - }); - - const rendered = inspect(uc.credential); - assert.ok( - !rendered.includes(SENTINEL_ACCESS), - `util.inspect(uc.credential) 泄露了 accessToken 明文: ${rendered}`, - ); - assert.ok( - !rendered.includes(SENTINEL_REFRESH), - `util.inspect(uc.credential) 泄露了 refreshToken 明文: ${rendered}`, - ); - - // 反向验证:直接属性访问必须仍是明文,否则调用方无法在进程退出前落库。 - assert.equal(uc.credential.accessToken, SENTINEL_ACCESS); - assert.equal(uc.credential.refreshToken, SENTINEL_REFRESH); -}); - -// --------------------------------------------------------------------------- -// §2.4:业务方法不带 ctx -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.4: uc.mark.add 业务方法不需要 ctx 参数,只传业务 body', async () => { - const {mockAgent, pool} = createMockAgent(); - let capturedBody: unknown; - let capturedHeaders: Record | undefined; - pool - .intercept({ - path: '/mark/v1/add', - method: 'POST', - body: raw => { - capturedBody = JSON.parse(raw as string); - return true; - }, - headers: h => { - capturedHeaders = h as Record; - return true; - }, - }) - .reply(200, businessSuccess({id: 'mark-1'})); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-mark-1', - refreshToken: 'rt-mark-1', - }); - - const result = await uc.mark.add({spuId: '12345'}); - - assert.equal(result.id, 'mark-1'); - assert.deepEqual(capturedBody, {spuId: '12345'}); - assert.equal( - capturedHeaders?.['access-token'], - 'at-mark-1', - 'uc.mark.add 只传业务 body,accessToken 应当由 withUserCredential 内部自动附加', - ); -}); - -// --------------------------------------------------------------------------- -// §2.5:genai 分组同样自动续期 -// --------------------------------------------------------------------------- - -void test('withUserCredential §2.5: genai 分组同样自动续期,401+10006 触发续期后用新 token 重试', async () => { - const {mockAgent, pool} = createMockAgent(); - - let firstAttemptCount = 0; - pool - .intercept({path: '/genai/v1/detail', method: 'GET', query: {id: 'task-1'}}) - .reply( - replyWith(401, businessFailure(10006, 'access_token 超过有效期'), () => { - firstAttemptCount++; - }), - ); - - let refreshCallCount = 0; - pool.intercept({path: '/auth/v1/refresh', method: 'POST'}).reply( - replyWith( - 200, - businessSuccess({ - accessToken: 'at-genai-refreshed', - expiresAt: String(nowSeconds() + 7200), - }), - () => { - refreshCallCount++; - }, - ), - ); - - let retryHeaders: Record | undefined; - pool - .intercept({path: '/genai/v1/detail', method: 'GET', query: {id: 'task-1'}}) - .reply( - replyWith( - 200, - businessSuccess({id: 'task-1', status: 'COMPLETED'}), - h => { - retryHeaders = h; - }, - ), - ); - - const qdmp = makeRootClient(mockAgent); - const uc = qdmp.withUserCredential({ - accessToken: 'at-genai-invalid', - refreshToken: 'rt-genai-1', - }); - - const result = await uc.genai.detail({id: 'task-1'}); - - assert.equal(result.id, 'task-1'); - assert.equal(firstAttemptCount, 1); - assert.equal( - refreshCallCount, - 1, - 'genai 分组的 401+10006 也应当恰好触发一次续期', - ); - assert.equal( - retryHeaders?.['x-openapi-access-token'], - 'at-genai-refreshed', - 'genai 重试请求必须携带续期后的新 accessToken(走 x-openapi-access-token 头)', - ); - assert.equal( - retryHeaders?.['access-token'], - undefined, - 'genai 方案不应携带 standard 方案专用的 access-token 头', - ); -}); From c4896488eef6ca5587253f456b66a99afb8c2807 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 22:18:08 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E4=B8=89?= =?UTF-8?q?=E7=AB=AF=E9=94=99=E8=AF=AF=E7=B1=BB=E5=9E=8B=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=EF=BC=8C=E6=B8=85=E7=90=86=E6=B5=8B=E8=AF=95=E9=87=8C=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E7=9A=84=E6=97=A7=E6=9C=AF=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++-- go/auth_expiresat_validation_test.go | 4 ++-- .../auth/QdmpAuthExpiresAtValidationTest.java | 4 ++-- ...dmpAuthUserAccessTokenFieldValidationTest.java | 15 ++++++++------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 71590ed..84b4741 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ const appCredential = await qdmp.auth.getAppAccessToken() // => { accessToken, expiresAt, refreshToken, openId }(openId 恒为空串) ``` -失败统一抛 `QdmpApiError`(业务失败)或 `QdmpValidationError`(本地参数校验失败,不发请求)。 +失败抛三类错误:`QdmpApiError`(业务失败,响应体 `code` 非 `'0'`)、`QdmpValidationError`(本地参数校验失败,不发请求)、`QdmpTransportError`(传输层问题,如收到重定向、响应体超过 10MB)。底层 `fetch` 本身的网络错误原样透出。 ## Java @@ -71,7 +71,7 @@ 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 diff --git a/go/auth_expiresat_validation_test.go b/go/auth_expiresat_validation_test.go index 0dfc918..1c721f8 100644 --- a/go/auth_expiresat_validation_test.go +++ b/go/auth_expiresat_validation_test.go @@ -31,7 +31,7 @@ import ( // but an expiresAt that isn't a number at all. 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", @@ -54,7 +54,7 @@ func TestAuthGetUserAccessToken_NonNumericExpiresAtRejected(t *testing.T) { // value that must never be treated as a usable absolute Unix-seconds expiry. 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", 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 e38a81b..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 @@ -99,7 +99,7 @@ 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 @@ -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) diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java index 929d103..34b8e7f 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java @@ -12,13 +12,14 @@ import org.junit.jupiter.api.Test; /** - * {@link QdmpAuth#getUserAccessToken}'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 QdmpAuthUserAccessTokenFieldValidationTest { From e21c9cc8e7703d1746788e4d8fafad1ee49cb508 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 3 Aug 2026 22:20:32 +0800 Subject: [PATCH 6/6] chore: bump version to 0.2.0 Co-Authored-By: Claude Opus 5 (1M context) --- java/build.gradle.kts | 2 +- node/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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": {