From 772795e7ebd519441d98111e555288d56b75032b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=97=E7=90=A6?= <442782435@qq.com> Date: Thu, 13 Aug 2026 13:39:41 +0800 Subject: [PATCH] feat: add Go current-session HTTP adapter --- .../20260813-go-auth-me-http-8d6f3a21.md | 85 ++++ backend/README.md | 13 +- backend/internal/application/application.go | 36 +- .../internal/application/application_test.go | 207 +++++++++ backend/internal/application/auth_config.go | 61 +++ .../internal/application/auth_config_test.go | 198 ++++++++ backend/internal/httpapi/auth_me.go | 164 +++++++ backend/internal/httpapi/auth_me_test.go | 437 ++++++++++++++++++ contracts/auth/current-session-v1.json | 172 +++++++ tests/auth-current-session-contract.test.ts | 242 ++++++++++ 10 files changed, 1608 insertions(+), 7 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260813-go-auth-me-http-8d6f3a21.md create mode 100644 backend/internal/application/auth_config.go create mode 100644 backend/internal/application/auth_config_test.go create mode 100644 backend/internal/httpapi/auth_me.go create mode 100644 backend/internal/httpapi/auth_me_test.go create mode 100644 contracts/auth/current-session-v1.json create mode 100644 tests/auth-current-session-contract.test.ts diff --git a/.project-docs/30-worklog/tasks/20260813-go-auth-me-http-8d6f3a21.md b/.project-docs/30-worklog/tasks/20260813-go-auth-me-http-8d6f3a21.md new file mode 100644 index 0000000..5f7b7b9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-go-auth-me-http-8d6f3a21.md @@ -0,0 +1,85 @@ +# Task: Implement Go current-session HTTP adapter + +## Identity + +- Task ID: 20260813-go-auth-me-http-8d6f3a21 +- Mode: Feature +- Branch: codex/20260813-go-auth-me-http-8d6f3a21-go-auth-me-http +- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-auth-me-8d6f3a21 +- Base commit: c8490775916c46141e06e3a86ea5bb76eb8c3f1d +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Freeze the current `GET /api/auth/me` response and transport behavior in a language-neutral contract consumed by TypeScript and Go tests. +- Add a Go HTTP Identity Adapter that reassembles the legacy chunked Cookie, delegates authorization to the existing deep Identity Resolver, and returns the current public session projection. +- Compose the Adapter into the runnable Go process for local/black-box verification while leaving Next.js, Ingress, Docker, ACK manifests, and production route ownership unchanged. +- Keep login, logout, password mutation, authorization policy for other routes, Middleware replacement, and production traffic cutover outside this slice. + +## Intent And Constraints + +- Use the external HTTP Interface and the existing `identity.Resolver.Resolve` Interface as the two agreed TDD seams; do not test private helper structure. +- Preserve the current `/api/auth/me` anonymous behavior: invalid, absent, expired, stale, disabled, or unauthorized sessions return HTTP 200 with `authenticated: false`, not 401. +- Preserve database/configuration failures as server failures rather than disguising them as anonymous sessions. +- Reuse the exact 20-name Cookie reassembly contract and database-authoritative session refresh; do not parse raw Cookie claims in the HTTP Module. +- Preserve `authRequired` and `authConfigured` semantics and the public user projection without exposing access tokens, token type, session version, or internal rejection reasons. +- Preserve Next.js method behavior: `HEAD` executes the GET path without a response body, `OPTIONS` returns 204 with `Allow: GET, HEAD, OPTIONS`, and unsupported standard methods return an empty 405 without `Allow`. +- Preserve the pinned Next.js parser's last-value-wins behavior for duplicate protected Cookie names, and treat reassembled values above the existing 60,000-character writer ceiling as anonymous before Resolver invocation. +- Keep ACK-001 deploy truth and all production path routing unchanged; the Go route remains unrouted externally in this task. + +## Outcome + +- Added `contracts/auth/current-session-v1.json` as the language-neutral v1 + contract for `/api/auth/me`: runtime auth configuration, method/status/header + behavior, anonymous and database-refreshed public projections, sensitive + session-field exclusion, and infrastructure-failure classification. +- Added a TypeScript contract consumer over the real auth config and App Route + `GET`, plus the pinned Next.js automatic method implementation for + `HEAD`/`OPTIONS`/unsupported methods. +- Added the Go `httpapi.NewAuthMeHandler` deep HTTP Module. It owns exact Cookie + chunk reassembly, Next-compatible duplicate-name handling, the 60,000-byte + reader guard, Identity Resolver error classification, method handling, and a + whitelist-only public response projection. +- Added an exact Go auth-config parser and application composition from + environment -> PostgreSQL Adapter -> Identity Resolver -> auth/me Handler. + Application tests prove a signed Cookie is refreshed from the persistence + snapshot and cannot preserve forged role/profile claims or leak token/session + metadata. +- Kept the Next.js route, Middleware, Docker, Compose, ACK manifests, Worker, + Secrets, and all production routing unchanged. The Go endpoint is runnable + only on the separate local process until a later path-level cutover. + +## Verification + +- TDD RED: the Go HTTP tests initially failed to compile because + `AuthState`, `SessionResolver`, and `NewAuthMeHandler` did not exist; the + application tests then failed before auth config and route composition were + implemented. Focused tests passed after each implementation slice. +- `npm test -- --reporter=dot`: 37 files and 141 tests passed. +- `npm run go:test`: all five Go packages passed. +- `npm run go:vet`: passed. +- `npm run go:build`: passed for `./cmd/zhinian-api`. +- `npx tsc --noEmit --incremental false --pretty false`: passed after the + concurrent `next build` process finished regenerating `.next/types`. +- `npm run build`: Next.js 15.5.18 production build passed with all 33 pages. +- `npm run deploy:check`: all 8 ACK manifest assertions passed. +- `git diff --check`, `gofmt -l backend`, project-docs validation, task doc + drift, and the forbidden production-routing diff check all passed. + +## Follow-ups + +- Add a local-account authorization snapshot Adapter if authenticated local-mode + Go development is required; today a configured valid Cookie on the local + backend fails with an explicit 500 rather than silently trusting claims. +- Add real PostgreSQL/RDS and verified-CA transport integration coverage before + any production auth route cutover. +- Migrate login, logout, password mutation, Middleware enforcement, and other + protected routes in later independently contract-tested slices. + +## Promotion Candidates + +- Promote `contracts/auth/current-session-v1.json` as the current-session HTTP + compatibility truth when the task is integrated. +- Record that the Go application can serve `/api/auth/me` locally while ACK-001 + and all production route ownership remain with Next.js. diff --git a/backend/README.md b/backend/README.md index 4a6b126..9115a43 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,7 +11,7 @@ Implemented Modules: account, organization, role, and `sessionVersion` authorization. - `postgres`: fail-closed configuration, verified-CA TLS, readiness, and calls to the existing atomic claim and wallet PostgreSQL functions. -- `httpapi`: process health and database readiness handlers. +- `httpapi`: process health, database readiness, and current-session handlers. - `application`: composition and the `cmd/zhinian-api` process entry point. From the repository root: @@ -31,9 +31,10 @@ server, use a different port: ZHINIAN_DATA_BACKEND=local GO_BACKEND_PORT=8080 ./backend/zhinian-api ``` -Only `/api/health` and `/api/ready` are implemented in this foundation. No -Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet. +`/api/health`, `/api/ready`, and `/api/auth/me` are implemented in the local Go +process. No Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go +yet, so Next.js remains the production owner of every route. -The Identity resolver and PostgreSQL authorization-snapshot Adapter are -implemented and tested, but no Go login or current-user HTTP route is exposed -yet. Route ownership remains with Next.js until a later path-level cutover. +The current-session handler reuses the Identity resolver and PostgreSQL +authorization-snapshot Adapter, but login, logout, password mutation, and +production route ownership remain with Next.js until later path-level cutover. diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index 38609ed..de2eae2 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -7,6 +7,7 @@ import ( "os" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" ) @@ -14,6 +15,10 @@ type Options struct { Context context.Context Getenv postgres.Getenv ReadFile postgres.ReadFile + // AuthorizationLoader is an optional Identity persistence Adapter used by + // composition tests and alternate runtime backends. Production defaults to + // the PostgreSQL Store opened below. + AuthorizationLoader identity.AuthorizationSnapshotLoader } type App struct { @@ -35,6 +40,10 @@ func New(options Options) (*App, error) { readFile = os.ReadFile } + authConfig, err := ParseAuthConfig(getenv) + if err != nil { + return nil, err + } config, err := postgres.ParseConfig(getenv, readFile) if err != nil { return nil, err @@ -43,10 +52,35 @@ func New(options Options) (*App, error) { if err != nil { return nil, err } + closeOnError := true + defer func() { + if closeOnError { + database.Close() + } + }() readiness := databaseReadiness{config: config, store: database.Store} + var resolver httpapi.SessionResolver + if authConfig.Configured { + loader := options.AuthorizationLoader + if loader == nil { + loader = database.Store + } + resolver = identity.NewResolver(loader, authConfig.SessionSecret, "platform", nil) + } + authMe, err := httpapi.NewAuthMeHandler(httpapi.AuthState{ + Required: authConfig.Required, Configured: authConfig.Configured, + }, resolver) + if err != nil { + return nil, err + } + foundation := httpapi.NewHandler(readiness) + mux := http.NewServeMux() + mux.Handle("/api/auth/me", authMe) + mux.Handle("/", foundation) + closeOnError = false return &App{ db: database, - handler: httpapi.NewHandler(readiness), + handler: mux, }, nil } diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go index 22c29d2..3d5f24b 100644 --- a/backend/internal/application/application_test.go +++ b/backend/internal/application/application_test.go @@ -1,12 +1,17 @@ package application_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" + "reflect" + "strings" "testing" + "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" ) func TestLocalApplicationServesFoundationHealthAndReadiness(t *testing.T) { @@ -66,3 +71,205 @@ func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T) t.Fatal("New() error = nil, want fail-closed database configuration error") } } + +func TestApplicationServesAnonymousCurrentSessionWithAuthConfigurationState(t *testing.T) { + tests := []struct { + name string + environment map[string]string + wantRequired bool + wantConfigured bool + }{ + { + name: "trusted local development", + environment: map[string]string{"ZHINIAN_DATA_BACKEND": "local"}, + wantRequired: false, + wantConfigured: false, + }, + { + name: "production missing session secret", + environment: map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + "NODE_ENV": "production", + }, + wantRequired: true, + wantConfigured: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app, err := application.New(application.Options{Getenv: applicationEnv(test.environment)}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + response := httptest.NewRecorder() + app.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) + } + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + want := map[string]any{ + "authenticated": false, + "authRequired": test.wantRequired, + "authConfigured": test.wantConfigured, + "authMode": nil, + "user": nil, + } + if !reflect.DeepEqual(payload, want) { + t.Fatalf("payload = %#v, want %#v", payload, want) + } + }) + } +} + +func TestApplicationComposesSignedCookieResolverWithAuthorizationLoader(t *testing.T) { + secret := "application-current-session-secret-with-enough-entropy" + loader := &applicationAuthorizationLoader{snapshot: identity.AuthorizationSnapshot{ + Account: identity.AccountSnapshot{ + ID: "user-1", Phone: "13800138001", DisplayName: "Current User", Role: "user", + OrganizationID: "org-1", Status: "active", SessionVersion: 7, + }, + Organization: &identity.OrganizationSnapshot{ID: "org-1", Name: "Primary Organization", Status: "active"}, + }, found: true} + app, err := application.New(application.Options{ + Getenv: applicationEnv(map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + "ZHINIAN_AUTH_SESSION_SECRET": secret, + }), + AuthorizationLoader: loader, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + version := 7 + session := identity.Session{ + Version: 1, AuthMode: identity.AuthModeAdmin, + IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(), + SessionVersion: &version, AccessToken: "must-not-leak", TokenType: "bearer", + User: identity.User{ + ID: "user-1", Subject: "forged", DisplayName: "Forged Admin", ClientID: "platform", + Role: "super_admin", Status: "active", Authorities: []string{"ROLE_SUPER_ADMIN"}, Scope: []string{"forged"}, + }, + } + raw, err := json.Marshal(session) + if err != nil { + t.Fatalf("marshal session: %v", err) + } + cookieValue, err := identity.Sign(raw, secret) + if err != nil { + t.Fatalf("sign session: %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue}) + response := httptest.NewRecorder() + + app.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) + } + body := response.Body.Bytes() + var payload struct { + Authenticated bool `json:"authenticated"` + User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Authorities []string `json:"authorities"` + } `json:"user"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.Authenticated || payload.User.ID != "user-1" || payload.User.DisplayName != "Current User" || payload.User.Role != "user" { + t.Fatalf("payload = %+v", payload) + } + if !reflect.DeepEqual(payload.User.Authorities, []string{"ROLE_USER"}) { + t.Fatalf("authorities = %#v", payload.User.Authorities) + } + if len(loader.ids) != 1 || loader.ids[0] != "user-1" { + t.Fatalf("loader IDs = %#v", loader.ids) + } + for _, forbidden := range []string{"accessToken", "tokenType", "sessionVersion", "expiresAt", "issuedAt", "must-not-leak"} { + if strings.Contains(string(body), forbidden) { + t.Fatalf("response leaked %q: %s", forbidden, body) + } + } + + for _, method := range []string{http.MethodHead, http.MethodOptions, http.MethodPost} { + methodRequest := httptest.NewRequest(method, "/api/auth/me", nil) + methodResponse := httptest.NewRecorder() + app.Handler().ServeHTTP(methodResponse, methodRequest) + wantStatus := http.StatusMethodNotAllowed + if method == http.MethodHead { + wantStatus = http.StatusOK + } + if method == http.MethodOptions { + wantStatus = http.StatusNoContent + } + if methodResponse.Code != wantStatus { + t.Fatalf("%s status = %d, want %d", method, methodResponse.Code, wantStatus) + } + } +} + +func TestApplicationUsesDatabaseAuthorizationAdapterByDefault(t *testing.T) { + secret := "local-default-adapter-secret-with-enough-entropy" + app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + "ZHINIAN_AUTH_SESSION_SECRET": secret, + })}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + session := identity.Session{ + Version: 1, AuthMode: identity.AuthModeUser, + IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(), + User: identity.User{ + ID: "user-1", Subject: "user-1", DisplayName: "User", ClientID: "platform", + Authorities: []string{}, Scope: []string{}, + }, + } + raw, err := json.Marshal(session) + if err != nil { + t.Fatalf("marshal session: %v", err) + } + cookieValue, err := identity.Sign(raw, secret) + if err != nil { + t.Fatalf("sign session: %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue}) + response := httptest.NewRecorder() + + app.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError || response.Body.Len() != 0 { + t.Fatalf("response = %d %q, want empty 500 from unavailable local authorization adapter", response.Code, response.Body.String()) + } +} + +type applicationAuthorizationLoader struct { + snapshot identity.AuthorizationSnapshot + found bool + err error + ids []string +} + +func (loader *applicationAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (identity.AuthorizationSnapshot, bool, error) { + loader.ids = append(loader.ids, id) + return loader.snapshot, loader.found, loader.err +} + +func applicationEnv(values map[string]string) func(string) string { + return func(name string) string { return values[name] } +} diff --git a/backend/internal/application/auth_config.go b/backend/internal/application/auth_config.go new file mode 100644 index 0000000..c337eac --- /dev/null +++ b/backend/internal/application/auth_config.go @@ -0,0 +1,61 @@ +package application + +import ( + "errors" + "strings" +) + +type AuthConfig struct { + Required bool + Configured bool + SessionSecret string +} + +func ParseAuthConfig(getenv func(string) string) (AuthConfig, error) { + if getenv == nil { + return AuthConfig{}, errors.New("auth config getenv is required") + } + + sessionSecret := firstAuthEnv(getenv, + "ZHINIAN_AUTH_SESSION_SECRET", + "AUTH_SESSION_SECRET", + "NEXTAUTH_SECRET", + ) + hasSecret := sessionSecret != "" + explicitRequired, hasExplicitRequired := authBool(getenv("ZHINIAN_AUTH_REQUIRED")) + disabled, _ := authBool(getenv("ZHINIAN_AUTH_DISABLED")) + + required := getenv("NODE_ENV") == "production" || hasSecret + if hasExplicitRequired { + required = explicitRequired + } + if disabled { + required = false + } + + return AuthConfig{ + Required: required, + Configured: (required || hasSecret) && hasSecret, + SessionSecret: sessionSecret, + }, nil +} + +func firstAuthEnv(getenv func(string) string, names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(getenv(name)); value != "" { + return value + } + } + return "" +} + +func authBool(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off": + return false, true + default: + return false, false + } +} diff --git a/backend/internal/application/auth_config_test.go b/backend/internal/application/auth_config_test.go new file mode 100644 index 0000000..466d336 --- /dev/null +++ b/backend/internal/application/auth_config_test.go @@ -0,0 +1,198 @@ +package application_test + +import ( + "encoding/json" + "os" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application" +) + +func TestParseAuthConfigMatchesCurrentSessionContract(t *testing.T) { + type expectedConfig struct { + Required bool `json:"required"` + Configured bool `json:"configured"` + SessionSecret *string `json:"sessionSecret"` + } + var fixture struct { + AuthConfigurationCases []struct { + Name string `json:"name"` + Environment map[string]string `json:"environment"` + Expected expectedConfig `json:"expected"` + } `json:"authConfigurationCases"` + } + + data, err := os.ReadFile("../../../contracts/auth/current-session-v1.json") + if err != nil { + t.Fatalf("read current-session contract: %v", err) + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("decode current-session contract: %v", err) + } + if len(fixture.AuthConfigurationCases) == 0 { + t.Fatal("current-session contract has no authConfigurationCases") + } + + for _, testCase := range fixture.AuthConfigurationCases { + t.Run(testCase.Name, func(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(testCase.Environment)) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + wantSecret := "" + if testCase.Expected.SessionSecret != nil { + wantSecret = *testCase.Expected.SessionSecret + } + want := application.AuthConfig{ + Required: testCase.Expected.Required, + Configured: testCase.Expected.Configured, + SessionSecret: wantSecret, + } + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } + }) + } +} + +func TestParseAuthConfigRejectsNilGetenv(t *testing.T) { + _, err := application.ParseAuthConfig(nil) + if err == nil { + t.Fatal("ParseAuthConfig(nil) error = nil, want error") + } +} + +func TestParseAuthConfigDefaultsToDisabledAndUnconfigured(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(nil)) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + want := application.AuthConfig{} + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } +} + +func TestParseAuthConfigUsesFirstTrimmedSessionSecret(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(map[string]string{ + "ZHINIAN_AUTH_SESSION_SECRET": " ", + "AUTH_SESSION_SECRET": " auth-secret ", + "NEXTAUTH_SECRET": "next-secret", + })) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + want := application.AuthConfig{Required: true, Configured: true, SessionSecret: "auth-secret"} + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } +} + +func TestParseAuthConfigRequiredPolicy(t *testing.T) { + tests := []struct { + name string + values map[string]string + want application.AuthConfig + }{ + { + name: "all accepted true values require auth", + values: map[string]string{ + "ZHINIAN_AUTH_REQUIRED": " YeS ", + }, + want: application.AuthConfig{Required: true}, + }, + { + name: "all accepted false values disable auth", + values: map[string]string{ + "ZHINIAN_AUTH_REQUIRED": " oFf ", + "ZHINIAN_AUTH_SESSION_SECRET": "secret", + }, + want: application.AuthConfig{Configured: true, SessionSecret: "secret"}, + }, + { + name: "disabled true overrides explicit required true", + values: map[string]string{ + "ZHINIAN_AUTH_DISABLED": "ON", + "ZHINIAN_AUTH_REQUIRED": "1", + "NEXTAUTH_SECRET": "secret", + }, + want: application.AuthConfig{Configured: true, SessionSecret: "secret"}, + }, + { + name: "disabled false does not override explicit required", + values: map[string]string{ + "ZHINIAN_AUTH_DISABLED": "no", + "ZHINIAN_AUTH_REQUIRED": "true", + }, + want: application.AuthConfig{Required: true}, + }, + { + name: "production defaults to required", + values: map[string]string{ + "NODE_ENV": "production", + }, + want: application.AuthConfig{Required: true}, + }, + { + name: "secret defaults to required and configured", + values: map[string]string{ + "NEXTAUTH_SECRET": " secret ", + }, + want: application.AuthConfig{Required: true, Configured: true, SessionSecret: "secret"}, + }, + { + name: "auto and invalid values use defaults", + values: map[string]string{ + "ZHINIAN_AUTH_REQUIRED": "auto", + "ZHINIAN_AUTH_DISABLED": "invalid", + }, + want: application.AuthConfig{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(test.values)) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + if config != test.want { + t.Fatalf("config = %+v, want %+v", config, test.want) + } + }) + } +} + +func TestParseAuthConfigRecognizesEveryBooleanSpelling(t *testing.T) { + for _, value := range []string{"1", "true", "yes", "on"} { + t.Run("true_"+value, func(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(map[string]string{"ZHINIAN_AUTH_REQUIRED": value})) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + if !config.Required { + t.Fatalf("Required = false for %q, want true", value) + } + }) + } + for _, value := range []string{"0", "false", "no", "off"} { + t.Run("false_"+value, func(t *testing.T) { + config, err := application.ParseAuthConfig(authEnv(map[string]string{ + "ZHINIAN_AUTH_REQUIRED": value, + "ZHINIAN_AUTH_SESSION_SECRET": "secret", + })) + if err != nil { + t.Fatalf("ParseAuthConfig() error = %v", err) + } + if config.Required { + t.Fatalf("Required = true for %q, want false", value) + } + }) + } +} + +func authEnv(values map[string]string) func(string) string { + return func(name string) string { + return values[name] + } +} diff --git a/backend/internal/httpapi/auth_me.go b/backend/internal/httpapi/auth_me.go new file mode 100644 index 0000000..7cfb10f --- /dev/null +++ b/backend/internal/httpapi/auth_me.go @@ -0,0 +1,164 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +// AuthState is the runtime authentication configuration exposed by auth/me. +type AuthState struct { + Required bool + Configured bool +} + +// SessionResolver is the HTTP module's consumer-owned seam to Identity. +type SessionResolver interface { + Resolve(context.Context, string) (identity.Session, error) +} + +type authMeHandler struct { + state AuthState + resolver SessionResolver +} + +// NewAuthMeHandler returns the standalone current-session HTTP adapter. +func NewAuthMeHandler(state AuthState, resolver SessionResolver) (http.Handler, error) { + if state.Configured && resolver == nil { + return nil, fmt.Errorf("auth/me: configured authentication requires a session resolver") + } + return &authMeHandler{state: state, resolver: resolver}, nil +} + +func (handler *authMeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/auth/me" { + w.WriteHeader(http.StatusNotFound) + return + } + + switch r.Method { + case http.MethodOptions: + w.Header().Set("Allow", "GET, HEAD, OPTIONS") + w.WriteHeader(http.StatusNoContent) + return + case http.MethodGet, http.MethodHead: + // Continue below: HEAD deliberately executes the same authentication + // work as GET and suppresses only the representation body. + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + response, err := handler.currentSession(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + payload, err := json.Marshal(response) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + _, _ = w.Write(payload) + } +} + +type authMeResponse struct { + Authenticated bool `json:"authenticated"` + AuthRequired bool `json:"authRequired"` + AuthConfigured bool `json:"authConfigured"` + AuthMode *identity.AuthMode `json:"authMode"` + User *publicUser `json:"user"` +} + +type publicUser struct { + ID string `json:"id"` + Subject string `json:"subject"` + Username string `json:"username,omitempty"` + Phone string `json:"phone,omitempty"` + DisplayName string `json:"displayName"` + ClientID string `json:"clientId"` + TenantID string `json:"tenantId,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` + OrganizationName string `json:"organizationName,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + Authorities []string `json:"authorities"` + Scope []string `json:"scope"` +} + +func (handler *authMeHandler) currentSession(r *http.Request) (authMeResponse, error) { + response := authMeResponse{ + AuthRequired: handler.state.Required, + AuthConfigured: handler.state.Configured, + } + if !handler.state.Configured { + return response, nil + } + + cookieValue, ok := readSessionCookie(r) + if !ok || len(cookieValue) > identity.CookieMaxValueLength { + return response, nil + } + session, err := handler.resolver.Resolve(r.Context(), cookieValue) + if errors.Is(err, identity.ErrUnauthenticated) { + return response, nil + } + if err != nil { + return authMeResponse{}, err + } + + authorities := session.User.Authorities + if authorities == nil { + authorities = []string{} + } + scope := session.User.Scope + if scope == nil { + scope = []string{} + } + response.Authenticated = true + response.AuthMode = &session.AuthMode + response.User = &publicUser{ + ID: session.User.ID, + Subject: session.User.Subject, + Username: session.User.Username, + Phone: session.User.Phone, + DisplayName: session.User.DisplayName, + ClientID: session.User.ClientID, + TenantID: session.User.TenantID, + OrganizationID: session.User.OrganizationID, + OrganizationName: session.User.OrganizationName, + Role: session.User.Role, + Status: session.User.Status, + Authorities: authorities, + Scope: scope, + } + return response, nil +} + +func readSessionCookie(r *http.Request) (string, bool) { + protected := make(map[string]struct{}, identity.CookieMaxChunks) + for _, name := range identity.CookieNames() { + protected[name] = struct{}{} + } + values := make(map[string]string, identity.CookieMaxChunks) + for _, cookie := range r.Cookies() { + if _, ok := protected[cookie.Name]; !ok { + continue + } + // Match the pinned Next.js RequestCookies parser: the final value for a + // duplicated name wins. net/http's Request.Cookie would choose the first. + values[cookie.Name] = cookie.Value + } + return identity.Reassemble(identity.SessionCookieName, func(name string) (string, bool) { + value, ok := values[name] + return value, ok + }) +} diff --git a/backend/internal/httpapi/auth_me_test.go b/backend/internal/httpapi/auth_me_test.go new file mode 100644 index 0000000..f2843ba --- /dev/null +++ b/backend/internal/httpapi/auth_me_test.go @@ -0,0 +1,437 @@ +package httpapi_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" +) + +func TestAuthMeReturnsAnonymousPublicProjectionWithoutCookie(t *testing.T) { + contract := loadCurrentSessionContract(t) + resolver := &sessionResolverStub{} + handler := newAuthMeHandler(t, authStateFromFixture(t, contract.Responses.Anonymous), resolver) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, contract.Path, nil)) + + assertFixtureJSONResponse(t, recorder, contract.Methods.GET.Status, contract.Methods.GET.ContentType, contract.Responses.Anonymous) + if resolver.calls != 0 { + t.Fatalf("Resolve calls = %d, want 0", resolver.calls) + } +} + +func TestAuthMeReturnsFixtureAuthenticatedPublicProjections(t *testing.T) { + contract := loadCurrentSessionContract(t) + tests := []struct { + name string + response json.RawMessage + }{ + {name: "authenticated user", response: contract.Responses.AuthenticatedUser}, + {name: "unbound super administrator", response: contract.Responses.UnboundSuperAdministrator}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := decodeFixtureResponse(t, test.response) + resolver := &sessionResolverStub{session: sessionFromFixture(t, fixture, contract.ForbiddenSessionKeys)} + handler := newAuthMeHandler(t, httpapi.AuthState{Required: fixture.AuthRequired, Configured: fixture.AuthConfigured}, resolver) + request := httptest.NewRequest(http.MethodGet, contract.Path, nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "part-0"}) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName + ".1", Value: "part-1"}) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + assertFixtureJSONResponse(t, recorder, contract.Methods.GET.Status, contract.Methods.GET.ContentType, test.response) + assertForbiddenSessionKeysAbsent(t, recorder.Body.Bytes(), contract.ForbiddenSessionKeys) + if resolver.calls != 1 || resolver.value != "part-0part-1" { + t.Fatalf("Resolve calls/value = %d/%q, want 1/%q", resolver.calls, resolver.value, "part-0part-1") + } + }) + } +} + +func TestAuthMeAnonymousCasesDoNotLeakResolverErrors(t *testing.T) { + tests := []struct { + name string + state httpapi.AuthState + cookies []*http.Cookie + resolverErr error + }{ + {name: "unconfigured", state: httpapi.AuthState{Required: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: "cookie"}}}, + {name: "unauthenticated", state: httpapi.AuthState{Required: true, Configured: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: "cookie"}}, resolverErr: identity.ErrUnauthenticated}, + {name: "over reader ceiling", state: httpapi.AuthState{Configured: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: strings.Repeat("x", identity.CookieMaxValueLength+1)}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := &sessionResolverStub{err: test.resolverErr} + handler := newAuthMeHandler(t, test.state, resolver) + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + for _, cookie := range test.cookies { + request.AddCookie(cookie) + } + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + assertFixtureJSONResponse(t, recorder, http.StatusOK, "application/json", json.RawMessage(`{"authenticated":false,"authRequired":`+boolJSON(test.state.Required)+`,"authConfigured":`+boolJSON(test.state.Configured)+`,"authMode":null,"user":null}`)) + wantCalls := 0 + if test.resolverErr != nil { + wantCalls = 1 + } + if resolver.calls != wantCalls { + t.Fatalf("Resolve calls = %d, want %d", resolver.calls, wantCalls) + } + }) + } +} + +func TestAuthMeDuplicateProtectedCookieUsesLastValueLikeNext(t *testing.T) { + resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} + handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "first"}) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "second"}) + + handler.ServeHTTP(httptest.NewRecorder(), request) + + if resolver.calls != 1 || resolver.value != "second" { + t.Fatalf("Resolve calls/value = %d/%q, want 1/%q", resolver.calls, resolver.value, "second") + } +} + +func TestAuthMeCookieReassemblyStopsAtGapAndIgnoresChunkTwenty(t *testing.T) { + resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} + handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + for _, cookie := range []*http.Cookie{ + {Name: identity.SessionCookieName, Value: "zero"}, + {Name: identity.SessionCookieName + ".1", Value: "one"}, + {Name: identity.SessionCookieName + ".3", Value: "three"}, + {Name: identity.SessionCookieName + ".20", Value: "twenty"}, + } { + request.AddCookie(cookie) + } + + handler.ServeHTTP(httptest.NewRecorder(), request) + + if resolver.value != "zeroone" { + t.Fatalf("Resolve value = %q, want %q", resolver.value, "zeroone") + } +} + +func TestAuthMeAcceptsExactReaderCeilingAcrossTwentyChunks(t *testing.T) { + resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} + handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + for index, name := range identity.CookieNames() { + request.AddCookie(&http.Cookie{Name: name, Value: strings.Repeat(string(rune('a'+index%26)), identity.CookieChunkSize)}) + } + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || resolver.calls != 1 || len(resolver.value) != identity.CookieMaxValueLength { + t.Fatalf("response/calls/value length = %d/%d/%d, want 200/1/%d", recorder.Code, resolver.calls, len(resolver.value), identity.CookieMaxValueLength) + } +} + +func TestAuthMeReturnsGenericEmptyServerFailure(t *testing.T) { + contract := loadCurrentSessionContract(t) + resolver := &sessionResolverStub{err: errors.New("database DSN secret leaked")} + handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) + request := httptest.NewRequest(http.MethodGet, contract.Path, nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "cookie"}) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + if recorder.Code != contract.InfrastructureError.TransportStatus || recorder.Body.Len() != 0 { + t.Fatalf("response = %d %q, want empty 500", recorder.Code, recorder.Body.String()) + } + if !contract.InfrastructureError.MustNotReturnAnonymous || contract.InfrastructureError.DirectGet != "rejects" { + t.Fatalf("invalid infrastructure error fixture: %+v", contract.InfrastructureError) + } + if got := recorder.Header().Get("Content-Type"); got != "" { + t.Fatalf("Content-Type = %q, want empty", got) + } +} + +func TestAuthMeHeadRunsSessionResolutionWithoutWritingBody(t *testing.T) { + resolver := &sessionResolverStub{session: identity.Session{ + AuthMode: identity.AuthModeUser, + User: identity.User{ + ID: "u", Subject: "u", DisplayName: "Ada", ClientID: "platform", + }, + }} + handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) + request := httptest.NewRequest(http.MethodHead, "/api/auth/me", nil) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "cookie"}) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 || resolver.calls != 1 { + t.Fatalf("response/resolver = %d %q / %d calls, want 200 empty / 1 call", recorder.Code, recorder.Body.String(), resolver.calls) + } + if got := recorder.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", got) + } +} + +func TestAuthMeMethodContract(t *testing.T) { + contract := loadCurrentSessionContract(t) + if contract.Version != 1 { + t.Fatalf("contract version = %d, want 1", contract.Version) + } + if contract.Methods.GET.Body != "json" || contract.Methods.HEAD.Body != "empty" || !contract.Methods.HEAD.ExecutesGet || + contract.Methods.OPTIONS.Body != "empty" || contract.Methods.Unsupported.Body != "empty" { + t.Fatalf("invalid method body semantics in fixture: %+v", contract.Methods) + } + handler := newAuthMeHandler(t, httpapi.AuthState{}, &sessionResolverStub{}) + tests := []struct { + method, path string + status int + allow string + contentType string + }{ + {method: http.MethodHead, path: contract.Path, status: contract.Methods.HEAD.Status, contentType: contract.Methods.HEAD.ContentType}, + {method: http.MethodOptions, path: contract.Path, status: contract.Methods.OPTIONS.Status, allow: contract.Methods.OPTIONS.Allow}, + } + for _, method := range contract.Methods.Unsupported.Methods { + tests = append(tests, struct { + method, path string + status int + allow string + contentType string + }{method: method, path: contract.Path, status: contract.Methods.Unsupported.Status}) + } + tests = append(tests, struct { + method, path string + status int + allow string + contentType string + }{method: http.MethodGet, path: contract.Path + "/", status: http.StatusNotFound}) + for _, test := range tests { + t.Run(test.method+" "+test.path, func(t *testing.T) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil)) + if recorder.Code != test.status || recorder.Body.Len() != 0 { + t.Fatalf("response = %d %q, want %d with empty body", recorder.Code, recorder.Body.String(), test.status) + } + if got := recorder.Header().Get("Allow"); got != test.allow { + t.Fatalf("Allow = %q, want %q", got, test.allow) + } + if got := recorder.Header().Get("Content-Type"); got != test.contentType { + t.Fatalf("Content-Type = %q, want %q", got, test.contentType) + } + }) + } +} + +func TestAuthMeRequiresResolverWhenAuthenticationIsConfigured(t *testing.T) { + handler, err := httpapi.NewAuthMeHandler(httpapi.AuthState{Configured: true}, nil) + if err == nil || handler != nil { + t.Fatalf("NewAuthMeHandler() = %#v, %v; want nil handler and error", handler, err) + } +} + +func newAuthMeHandler(t *testing.T, state httpapi.AuthState, resolver httpapi.SessionResolver) http.Handler { + t.Helper() + handler, err := httpapi.NewAuthMeHandler(state, resolver) + if err != nil { + t.Fatalf("NewAuthMeHandler: %v", err) + } + return handler +} + +func assertFixtureJSONResponse(t *testing.T, recorder *httptest.ResponseRecorder, status int, contentType string, wantJSON json.RawMessage) { + t.Helper() + if recorder.Code != status { + t.Fatalf("status = %d, want %d", recorder.Code, status) + } + if got := recorder.Header().Get("Content-Type"); got != contentType { + t.Fatalf("Content-Type = %q, want %q", got, contentType) + } + var got, want any + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if err := json.Unmarshal(wantJSON, &want); err != nil { + t.Fatalf("decode expected JSON: %v", err) + } + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("encode response: %v", err) + } + wantJSON, err = json.Marshal(want) + if err != nil { + t.Fatalf("encode expected response: %v", err) + } + if string(gotJSON) != string(wantJSON) { + t.Fatalf("response = %#v, want %#v", got, want) + } +} + +func loadCurrentSessionContract(t *testing.T) currentSessionContract { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate auth_me_test.go") + } + path := filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "auth", "current-session-v1.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read current-session fixture: %v", err) + } + var contract currentSessionContract + if err := json.Unmarshal(raw, &contract); err != nil { + t.Fatalf("decode current-session fixture: %v", err) + } + if contract.Path == "" || len(contract.Responses.Anonymous) == 0 || len(contract.Responses.AuthenticatedUser) == 0 || len(contract.Responses.UnboundSuperAdministrator) == 0 { + t.Fatal("current-session fixture is missing required path or responses") + } + return contract +} + +func decodeFixtureResponse(t *testing.T, raw json.RawMessage) fixtureResponse { + t.Helper() + var response fixtureResponse + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("decode fixture response: %v", err) + } + return response +} + +func authStateFromFixture(t *testing.T, raw json.RawMessage) httpapi.AuthState { + t.Helper() + response := decodeFixtureResponse(t, raw) + return httpapi.AuthState{Required: response.AuthRequired, Configured: response.AuthConfigured} +} + +func sessionFromFixture(t *testing.T, response fixtureResponse, forbiddenKeys []string) identity.Session { + t.Helper() + if !response.Authenticated || response.User == nil { + t.Fatal("authenticated fixture response must include a user") + } + mode := identity.AuthMode(response.AuthMode) + session := identity.Session{ + Version: 1, AuthMode: mode, IssuedAt: 123, ExpiresAt: 456, + AccessToken: "must-not-leak", TokenType: "Bearer", + User: identity.User{ + ID: response.User.ID, Subject: response.User.Subject, Username: response.User.Username, + Phone: response.User.Phone, DisplayName: response.User.DisplayName, ClientID: response.User.ClientID, + TenantID: response.User.TenantID, OrganizationID: response.User.OrganizationID, + OrganizationName: response.User.OrganizationName, Role: response.User.Role, Status: response.User.Status, + Authorities: response.User.Authorities, Scope: response.User.Scope, + }, + } + version := 9 + session.SessionVersion = &version + if len(forbiddenKeys) == 0 { + t.Fatal("fixture must identify forbidden session keys") + } + return session +} + +func assertForbiddenSessionKeysAbsent(t *testing.T, raw []byte, forbiddenKeys []string) { + t.Helper() + var response map[string]json.RawMessage + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("decode response keys: %v", err) + } + for _, key := range forbiddenKeys { + if _, found := response[key]; found { + t.Errorf("forbidden session key %q leaked in response", key) + } + } +} + +func boolJSON(value bool) string { + if value { + return "true" + } + return "false" +} + +type sessionResolverStub struct { + session identity.Session + err error + calls int + value string +} + +type currentSessionContract struct { + Version int `json:"version"` + Path string `json:"path"` + Methods struct { + GET fixtureMethod `json:"GET"` + HEAD fixtureMethod `json:"HEAD"` + OPTIONS fixtureMethod `json:"OPTIONS"` + Unsupported struct { + Methods []string `json:"methods"` + Status int `json:"status"` + Body string `json:"body"` + ContentType *string `json:"contentType"` + Allow *string `json:"allow"` + } `json:"unsupported"` + } `json:"methods"` + Responses struct { + Anonymous json.RawMessage `json:"anonymous"` + AuthenticatedUser json.RawMessage `json:"authenticatedUser"` + UnboundSuperAdministrator json.RawMessage `json:"unboundSuperAdministrator"` + } `json:"responses"` + ForbiddenSessionKeys []string `json:"forbiddenSessionKeys"` + InfrastructureError struct { + DirectGet string `json:"directGet"` + TransportStatus int `json:"transportStatus"` + MustNotReturnAnonymous bool `json:"mustNotReturnAnonymous"` + } `json:"infrastructureError"` +} + +type fixtureMethod struct { + Status int `json:"status"` + Body string `json:"body"` + ContentType string `json:"contentType"` + Allow string `json:"allow"` + ExecutesGet bool `json:"executesGet"` +} + +type fixtureResponse struct { + Authenticated bool `json:"authenticated"` + AuthRequired bool `json:"authRequired"` + AuthConfigured bool `json:"authConfigured"` + AuthMode string `json:"authMode"` + User *fixtureUser `json:"user"` +} + +type fixtureUser struct { + ID string `json:"id"` + Subject string `json:"subject"` + Username string `json:"username"` + Phone string `json:"phone"` + DisplayName string `json:"displayName"` + ClientID string `json:"clientId"` + TenantID string `json:"tenantId"` + OrganizationID string `json:"organizationId"` + OrganizationName string `json:"organizationName"` + Role string `json:"role"` + Status string `json:"status"` + Authorities []string `json:"authorities"` + Scope []string `json:"scope"` +} + +func (stub *sessionResolverStub) Resolve(_ context.Context, value string) (identity.Session, error) { + stub.calls++ + stub.value = value + return stub.session, stub.err +} diff --git a/contracts/auth/current-session-v1.json b/contracts/auth/current-session-v1.json new file mode 100644 index 0000000..9836f50 --- /dev/null +++ b/contracts/auth/current-session-v1.json @@ -0,0 +1,172 @@ +{ + "version": 1, + "path": "/api/auth/me", + "methods": { + "GET": { + "status": 200, + "body": "json", + "contentType": "application/json", + "allow": null + }, + "HEAD": { + "status": 200, + "body": "empty", + "contentType": "application/json", + "allow": null, + "executesGet": true + }, + "OPTIONS": { + "status": 204, + "body": "empty", + "contentType": null, + "allow": "GET, HEAD, OPTIONS" + }, + "unsupported": { + "methods": ["POST", "PUT", "PATCH", "DELETE"], + "status": 405, + "body": "empty", + "contentType": null, + "allow": null + } + }, + "authConfigurationCases": [ + { + "name": "development without a secret is optional and unconfigured", + "environment": { + "NODE_ENV": "development" + }, + "expected": { + "required": false, + "configured": false, + "sessionSecret": null + } + }, + { + "name": "production without a secret is required and unconfigured", + "environment": { + "NODE_ENV": "production" + }, + "expected": { + "required": true, + "configured": false, + "sessionSecret": null + } + }, + { + "name": "explicit false with a secret remains configured", + "environment": { + "NODE_ENV": "production", + "ZHINIAN_AUTH_REQUIRED": " off ", + "ZHINIAN_AUTH_SESSION_SECRET": " explicit-secret " + }, + "expected": { + "required": false, + "configured": true, + "sessionSecret": "explicit-secret" + } + }, + { + "name": "disabled with a secret remains configured", + "environment": { + "NODE_ENV": "production", + "ZHINIAN_AUTH_REQUIRED": "yes", + "ZHINIAN_AUTH_DISABLED": " ON ", + "ZHINIAN_AUTH_SESSION_SECRET": "disabled-secret" + }, + "expected": { + "required": false, + "configured": true, + "sessionSecret": "disabled-secret" + } + }, + { + "name": "the primary trimmed secret wins over legacy fallbacks", + "environment": { + "NODE_ENV": "development", + "ZHINIAN_AUTH_REQUIRED": "auto", + "ZHINIAN_AUTH_SESSION_SECRET": " primary-secret ", + "AUTH_SESSION_SECRET": "secondary-secret", + "NEXTAUTH_SECRET": "legacy-secret" + }, + "expected": { + "required": true, + "configured": true, + "sessionSecret": "primary-secret" + } + }, + { + "name": "a blank primary secret falls through and truthy booleans ignore case and whitespace", + "environment": { + "NODE_ENV": "development", + "ZHINIAN_AUTH_REQUIRED": " TrUe ", + "ZHINIAN_AUTH_SESSION_SECRET": " ", + "AUTH_SESSION_SECRET": " fallback-secret " + }, + "expected": { + "required": true, + "configured": true, + "sessionSecret": "fallback-secret" + } + } + ], + "responses": { + "anonymous": { + "authenticated": false, + "authRequired": false, + "authConfigured": false, + "authMode": null, + "user": null + }, + "authenticatedUser": { + "authenticated": true, + "authRequired": true, + "authConfigured": true, + "authMode": "user", + "user": { + "id": "user-1", + "subject": "user-1", + "username": "13800138001", + "phone": "13800138001", + "displayName": "普通用户", + "clientId": "platform", + "organizationId": "org-1", + "organizationName": "第一组织", + "role": "user", + "status": "active", + "authorities": ["ROLE_USER"], + "scope": [] + } + }, + "unboundSuperAdministrator": { + "authenticated": true, + "authRequired": true, + "authConfigured": true, + "authMode": "admin", + "user": { + "id": "super-1", + "subject": "super-1", + "username": "13800138003", + "phone": "13800138003", + "displayName": "平台超级管理员", + "clientId": "platform", + "role": "super_admin", + "status": "active", + "authorities": ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"], + "scope": [] + } + } + }, + "forbiddenSessionKeys": [ + "version", + "issuedAt", + "expiresAt", + "sessionVersion", + "accessToken", + "tokenType" + ], + "infrastructureError": { + "directGet": "rejects", + "transportStatus": 500, + "mustNotReturnAnonymous": true + } +} diff --git a/tests/auth-current-session-contract.test.ts b/tests/auth-current-session-contract.test.ts new file mode 100644 index 0000000..62d152c --- /dev/null +++ b/tests/auth-current-session-contract.test.ts @@ -0,0 +1,242 @@ +import { readFile } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; + +import { getAuthRuntimeConfig } from "@/lib/auth/config"; +import type { AuthSession } from "@/lib/auth/session"; + +const { getOptionalAuthSession } = vi.hoisted(() => ({ + getOptionalAuthSession: vi.fn<() => Promise>() +})); + +vi.mock("@/lib/server/auth/current-user", () => ({ getOptionalAuthSession })); + +import * as currentSessionRoute from "@/app/api/auth/me/route"; + +type ExpectedConfig = { + required: boolean; + configured: boolean; + sessionSecret: string | null; +}; + +type CurrentSessionFixture = { + version: 1; + path: string; + methods: { + GET: MethodContract; + HEAD: MethodContract & { executesGet: true }; + OPTIONS: MethodContract; + unsupported: MethodContract & { methods: string[] }; + }; + authConfigurationCases: Array<{ + name: string; + environment: Record; + expected: ExpectedConfig; + }>; + responses: { + anonymous: CurrentSessionResponse; + authenticatedUser: CurrentSessionResponse; + unboundSuperAdministrator: CurrentSessionResponse; + }; + forbiddenSessionKeys: string[]; + infrastructureError: { + directGet: "rejects"; + transportStatus: number; + mustNotReturnAnonymous: boolean; + }; +}; + +type MethodContract = { + status: number; + body: "json" | "empty"; + contentType: string | null; + allow: string | null; +}; + +type CurrentSessionResponse = { + authenticated: boolean; + authRequired: boolean; + authConfigured: boolean; + authMode: "user" | "admin" | null; + user: Record | null; +}; + +const fixtureUrl = new URL("../contracts/auth/current-session-v1.json", import.meta.url); +const require = createRequire(import.meta.url); +const authEnvironmentKeys = [ + "NODE_ENV", + "ZHINIAN_AUTH_REQUIRED", + "ZHINIAN_AUTH_DISABLED", + "ZHINIAN_AUTH_SESSION_SECRET", + "AUTH_SESSION_SECRET", + "NEXTAUTH_SECRET" +] as const; +const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]])); + +afterEach(() => { + getOptionalAuthSession.mockReset(); + restoreAuthEnvironment(); +}); + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as CurrentSessionFixture; +} + +function restoreAuthEnvironment() { + for (const key of authEnvironmentKeys) { + const original = originalEnvironment.get(key); + if (original === undefined) Reflect.deleteProperty(process.env, key); + else Reflect.set(process.env, key, original); + } +} + +function configureAuthEnvironment(environment: Record) { + for (const key of authEnvironmentKeys) Reflect.deleteProperty(process.env, key); + for (const [key, value] of Object.entries(environment)) Reflect.set(process.env, key, value); +} + +function sessionFor(response: CurrentSessionResponse): AuthSession { + if (!response.user || !response.authMode) throw new Error("authenticated fixture response required"); + return { + version: 1, + authMode: response.authMode, + issuedAt: 100, + expiresAt: 200, + sessionVersion: 7, + accessToken: "must-not-leak", + tokenType: "bearer", + user: response.user as AuthSession["user"] + }; +} + +async function expectJsonResponse(expected: CurrentSessionResponse) { + const fixture = await loadFixture(); + const response = await currentSessionRoute.GET(); + + expect(response.status).toBe(fixture.methods.GET.status); + expect(response.headers.get("content-type")).toBe(fixture.methods.GET.contentType); + expect(response.headers.get("allow")).toBe(fixture.methods.GET.allow); + expect(await response.json()).toEqual(expected); +} + +describe("current session HTTP v1 cross-language contract", () => { + it("freezes the path and Next.js automatic method semantics", async () => { + const fixture = await loadFixture(); + + expect(fixture.version).toBe(1); + expect(fixture.path).toBe("/api/auth/me"); + expect(Object.keys(currentSessionRoute).sort()).toEqual(["GET", "runtime"]); + expect(currentSessionRoute.runtime).toBe("nodejs"); + expect(fixture.methods).toEqual({ + GET: { status: 200, body: "json", contentType: "application/json", allow: null }, + HEAD: { + status: 200, + body: "empty", + contentType: "application/json", + allow: null, + executesGet: true + }, + OPTIONS: { status: 204, body: "empty", contentType: null, allow: "GET, HEAD, OPTIONS" }, + unsupported: { + methods: ["POST", "PUT", "PATCH", "DELETE"], + status: 405, + body: "empty", + contentType: null, + allow: null + } + }); + + const { autoImplementMethods } = require( + "next/dist/server/route-modules/app-route/helpers/auto-implement-methods.js" + ) as { + autoImplementMethods: (handlers: Record) => Record Promise>; + }; + const handlers = autoImplementMethods({ GET: currentSessionRoute.GET }); + getOptionalAuthSession.mockResolvedValue(null); + configureAuthEnvironment({ NODE_ENV: "development" }); + + for (const method of ["HEAD", "OPTIONS", ...fixture.methods.unsupported.methods]) { + const response = await handlers[method](); + const expected = method === "HEAD" + ? fixture.methods.HEAD + : method === "OPTIONS" + ? fixture.methods.OPTIONS + : fixture.methods.unsupported; + expect(response.status, method).toBe(expected.status); + expect(response.headers.get("content-type"), method).toBe(expected.contentType); + expect(response.headers.get("allow"), method).toBe(expected.allow); + if (method !== "HEAD") expect(await response.text(), method).toBe(""); + } + }); + + it("consumes every auth configuration case through the real runtime resolver", async () => { + const fixture = await loadFixture(); + + expect(fixture.authConfigurationCases.length).toBeGreaterThanOrEqual(4); + for (const testCase of fixture.authConfigurationCases) { + configureAuthEnvironment(testCase.environment); + const config = getAuthRuntimeConfig(); + expect( + { + required: config.required, + configured: config.configured, + sessionSecret: config.sessionSecret ?? null + }, + testCase.name + ).toEqual(testCase.expected); + } + }); + + it("returns the exact five-key anonymous response", async () => { + const fixture = await loadFixture(); + configureAuthEnvironment({ NODE_ENV: "development" }); + getOptionalAuthSession.mockResolvedValue(null); + + expect(Object.keys(fixture.responses.anonymous).sort()).toEqual( + ["authenticated", "authRequired", "authConfigured", "authMode", "user"].sort() + ); + await expectJsonResponse(fixture.responses.anonymous); + }); + + it.each(["authenticatedUser", "unboundSuperAdministrator"] as const)( + "returns the exact public projection for %s and omits optional fields", + async (caseName) => { + const fixture = await loadFixture(); + const expected = fixture.responses[caseName]; + configureAuthEnvironment({ + NODE_ENV: "production", + ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret" + }); + getOptionalAuthSession.mockResolvedValue(sessionFor(expected)); + + await expectJsonResponse(expected); + const serialized = JSON.stringify(expected); + for (const key of fixture.forbiddenSessionKeys) { + expect(serialized, `${caseName} leaked ${key}`).not.toContain(`"${key}"`); + } + if (caseName === "unboundSuperAdministrator") { + expect(expected.user).not.toHaveProperty("tenantId"); + expect(expected.user).not.toHaveProperty("organizationId"); + expect(expected.user).not.toHaveProperty("organizationName"); + } + } + ); + + it("propagates infrastructure errors for transport mapping instead of returning anonymous", async () => { + const fixture = await loadFixture(); + const failure = new Error("authorization snapshot unavailable"); + configureAuthEnvironment({ + NODE_ENV: "production", + ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret" + }); + getOptionalAuthSession.mockRejectedValue(failure); + + expect(fixture.infrastructureError).toEqual({ + directGet: "rejects", + transportStatus: 500, + mustNotReturnAnonymous: true + }); + await expect(currentSessionRoute.GET()).rejects.toBe(failure); + }); +});