Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/generate-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,6 @@ jobs:
run: npm ci
working-directory: node

- name: install oapi-codegen
run: |
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"

- name: regenerate everything from shared/openapi.yaml
run: bash shared/scripts/regenerate-all.sh

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/go-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ jobs:
- name: staticcheck
uses: dominikh/staticcheck-action@v1
with:
version: latest
version: "2026.1"
working-directory: go
install-go: false
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
- Node.js ≥ 22
- JDK ≥ 17(CI 用 temurin 17)
- Go(版本见 `go/go.mod`)
- [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen):`go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest`,装完确保 `$(go env GOPATH)/bin` 在 `PATH` 上
- `openapi-generator-cli`(Java model 生成用):`shared/scripts/regenerate-all.sh` 会在 `OPENAPI_GENERATOR_CLI_JAR` 未指向已存在的 jar 时自动下载到 `/tmp/tools/openapi-generator-cli.jar`,无需手动安装
- [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen):无需手动安装,`shared/scripts/regenerate-all.sh` 通过 `go run .../oapi-codegen@v2.8.0` 按固定版本直接调用,Go 工具链会对该 module 做 sum.golang.org 校验
- `openapi-generator-cli`(Java model 生成用):`shared/scripts/regenerate-all.sh` 会在 `OPENAPI_GENERATOR_CLI_JAR` 未指向已存在的 jar 时自动下载到本地缓存并校验 SHA-256,无需手动安装;若校验失败会重新下载一次,仍失败则直接报错退出

## 代码生成

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ QdmpClient qdmp = new QdmpClient(
.build());

// 用 code 换用户 session(SDK 不缓存,自行持久化)
AuthToken200ResponseAllOfData session = qdmp.auth().code2Session(code);
Code2SessionResult session = qdmp.auth().code2Session(code);

// QdmpContext.of(accessToken) 是唯一入口,空/非法 token 在构造期就失败
QdmpContext ctx = QdmpContext.of(session.getAccessToken());
Expand Down
133 changes: 131 additions & 2 deletions go/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"sync"
Expand Down Expand Up @@ -38,6 +39,77 @@ type RefreshTokenResult struct {
ExpiresAt string `json:"expiresAt"`
}

// String implements fmt.Stringer so that accidental debug-printing (e.g.
// 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
// 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
// (result.AccessToken) is likewise untouched.
func (r Code2SessionResult) String() string {
return fmt.Sprintf(
"Code2SessionResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}",
"[REDACTED]", "[REDACTED]", r.ExpiresAt, r.OpenID,
)
}

// String implements fmt.Stringer — see Code2SessionResult.String for why.
func (r RefreshTokenResult) String() string {
return fmt.Sprintf(
"RefreshTokenResult{AccessToken:%q, ExpiresAt:%q}",
"[REDACTED]", r.ExpiresAt,
)
}

// GoString implements fmt.GoStringer. The %#v verb does NOT consult
// fmt.Stringer — it falls back to Go-syntax struct reflection, which would
// 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 {
return fmt.Sprintf(
"qdmp.Code2SessionResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}",
"[REDACTED]", "[REDACTED]", r.ExpiresAt, r.OpenID,
)
}

// GoString implements fmt.GoStringer — see Code2SessionResult.GoString for why.
func (r RefreshTokenResult) GoString() string {
return fmt.Sprintf(
"qdmp.RefreshTokenResult{AccessToken:%q, ExpiresAt:%q}",
"[REDACTED]", r.ExpiresAt,
)
}

// LogValue implements slog.LogValuer. Structured loggers built on log/slog
// (e.g. slog.JSONHandler, slog.TextHandler) do NOT consult fmt.Stringer —
// slog.JSONHandler JSON-marshals values directly, which would still emit
// the raw AccessToken/RefreshToken fields even with String()/GoString()
// 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 {
return slog.GroupValue(
slog.String("AccessToken", "[REDACTED]"),
slog.String("RefreshToken", "[REDACTED]"),
slog.String("ExpiresAt", r.ExpiresAt),
slog.String("OpenID", r.OpenID),
)
}

// LogValue implements slog.LogValuer — see Code2SessionResult.LogValue for why.
func (r RefreshTokenResult) LogValue() slog.Value {
return slog.GroupValue(
slog.String("AccessToken", "[REDACTED]"),
slog.String("ExpiresAt", r.ExpiresAt),
)
}

// 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).
Expand Down Expand Up @@ -174,15 +246,45 @@ func (a *AuthService) exchangeClientCredentials(ctx context.Context) (string, er
return "", fmt.Errorf("qdmp: auth.token response has an empty accessToken")
}

expiresAt, err := strconv.ParseInt(result.ExpiresAt, 10, 64)
expiresAt, err := parseExpiresAt(result.ExpiresAt)
if err != nil {
return "", fmt.Errorf("qdmp: auth.token response has a non-numeric expiresAt: %w", err)
return "", 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
// seconds from now") — parseExpiresAt only rejects negative/non-numeric
// values. Accepting such a value here would cache it and hand it back to
// this call's own caller as if it were a usably-fresh token, even though
// cachedValid uses the same buffer and would immediately (and correctly)
// treat that same cached entry as not-fresh on the very next call. Apply
// 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)
}

a.store.Set(TokenEntry{AccessToken: result.AccessToken, ExpiresAt: expiresAt})
return result.AccessToken, nil
}

// parseExpiresAt parses a wire expiresAt string (see Code2SessionResult'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
// negative/corrupted expiresAt must never be treated as a usable expiry
// (whether cached or returned directly to a one-shot caller), rather than
// silently limping along.
func parseExpiresAt(s string) (int64, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("non-numeric expiresAt %q: %w", s, err)
}
if v < 0 {
return 0, fmt.Errorf("negative expiresAt %q", s)
}
return v, nil
}

// Code2Session 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) {
Expand All @@ -208,6 +310,24 @@ func (a *AuthService) Code2Session(ctx context.Context, code string) (*Code2Sess
if result.AccessToken == "" {
return nil, fmt.Errorf("qdmp: auth.code2Session response has an empty accessToken")
}
if result.RefreshToken == "" {
return nil, fmt.Errorf("qdmp: auth.code2Session response has an empty refreshToken")
}
if result.OpenID == "" {
return nil, fmt.Errorf("qdmp: auth.code2Session 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)
}
// 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.
if expiresAt <= time.Now().Unix() {
return nil, fmt.Errorf("qdmp: auth.code2Session response has an already-expired expiresAt (%d)", expiresAt)
}
return &result, nil
}

Expand All @@ -233,5 +353,14 @@ func (a *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*R
if result.AccessToken == "" {
return nil, fmt.Errorf("qdmp: auth.refreshToken response has an empty accessToken")
}
expiresAt, err := parseExpiresAt(result.ExpiresAt)
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
// 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)
}
return &result, nil
}
105 changes: 105 additions & 0 deletions go/auth_code2session_fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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
// 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
// without a refreshToken to renew it later, or an openId to know whose
// session it is.

import (
"context"
"net/http"
"testing"
)

// TestAuthCode2Session_MissingRefreshTokenRejected drives a response that
// omits the refreshToken field entirely (as opposed to sending it empty).
func TestAuthCode2Session_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",
"expiresAt": "1785508950",
"openId": "user-openid-1",
// refreshToken deliberately omitted.
})
}))
client := newTestClient(t, srv.URL)

sess, err := client.Auth.Code2Session(context.Background(), "some-code")
if err == nil {
t.Fatalf("Code2Session() 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)
}
}

// TestAuthCode2Session_EmptyRefreshTokenRejected is the same failure mode
// but with an explicit empty string rather than an omitted field.
func TestAuthCode2Session_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",
"refreshToken": "",
"expiresAt": "1785508950",
"openId": "user-openid-1",
})
}))
client := newTestClient(t, srv.URL)

sess, err := client.Auth.Code2Session(context.Background(), "some-code")
if err == nil {
t.Fatalf("Code2Session() error = nil, want an error because refreshToken is an empty string")
}
if sess != nil {
t.Fatalf("Code2Session() result = %+v, want nil on failure", sess)
}
}

// TestAuthCode2Session_MissingOpenIDRejected drives a response that omits
// the openId field entirely.
func TestAuthCode2Session_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",
"refreshToken": "user-level-refresh-token",
"expiresAt": "1785508950",
// openId deliberately omitted.
})
}))
client := newTestClient(t, srv.URL)

sess, err := client.Auth.Code2Session(context.Background(), "some-code")
if err == nil {
t.Fatalf("Code2Session() 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)
}
}

// TestAuthCode2Session_EmptyOpenIDRejected is the same failure mode but with
// an explicit empty string rather than an omitted field.
func TestAuthCode2Session_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",
"refreshToken": "user-level-refresh-token",
"expiresAt": "1785508950",
"openId": "",
})
}))
client := newTestClient(t, srv.URL)

sess, err := client.Auth.Code2Session(context.Background(), "some-code")
if err == nil {
t.Fatalf("Code2Session() error = nil, want an error because openId is an empty string")
}
if sess != nil {
t.Fatalf("Code2Session() result = %+v, want nil on failure", sess)
}
}
Loading
Loading