293 lines
10 KiB
Go
293 lines
10 KiB
Go
package identity
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type sessionFixture struct {
|
|
Version int `json:"version"`
|
|
Cookie sessionCookieContract `json:"cookie"`
|
|
Secret string `json:"secret"`
|
|
RawJSON string `json:"rawJson"`
|
|
Payload string `json:"payload"`
|
|
Signature string `json:"signature"`
|
|
CookieValue string `json:"cookieValue"`
|
|
}
|
|
|
|
type sessionCookieContract struct {
|
|
Name string `json:"name"`
|
|
ChunkSize int `json:"chunkSize"`
|
|
MaxChunks int `json:"maxChunks"`
|
|
MaxValueLength int `json:"maxValueLength"`
|
|
ChunkNames []string `json:"chunkNames"`
|
|
WriteExample struct {
|
|
ValueCharacter string `json:"valueCharacter"`
|
|
ValueLength int `json:"valueLength"`
|
|
ChunkLengths []int `json:"chunkLengths"`
|
|
ExpiresAtUnix int64 `json:"expiresAtUnix"`
|
|
} `json:"writeExample"`
|
|
Attributes struct {
|
|
HTTPOnly bool `json:"httpOnly"`
|
|
SameSite string `json:"sameSite"`
|
|
Path string `json:"path"`
|
|
ProductionSecure bool `json:"productionSecure"`
|
|
} `json:"attributes"`
|
|
Clear struct {
|
|
Value string `json:"value"`
|
|
MaxAgeSeconds int `json:"maxAgeSeconds"`
|
|
} `json:"clear"`
|
|
SecureResolutionCases []struct {
|
|
Name string `json:"name"`
|
|
Explicit string `json:"explicit"`
|
|
PublicBaseURL string `json:"publicBaseUrl"`
|
|
RequestURL string `json:"requestUrl"`
|
|
Expected bool `json:"expected"`
|
|
} `json:"secureResolutionCases"`
|
|
}
|
|
|
|
func TestSignMatchesTypeScriptFixture(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
const expectedSignature = "KTBRAoo5dytZJhvzX9qqd5JFnfVm7hEQd57TW1JSk4M"
|
|
if fixture.Signature != expectedSignature {
|
|
t.Fatalf("fixture signature = %q, want deterministic vector %q", fixture.Signature, expectedSignature)
|
|
}
|
|
|
|
got, err := Sign([]byte(fixture.RawJSON), fixture.Secret)
|
|
if err != nil {
|
|
t.Fatalf("Sign() error = %v", err)
|
|
}
|
|
if got != fixture.CookieValue {
|
|
t.Fatalf("Sign() = %q, want fixture cookie %q", got, fixture.CookieValue)
|
|
}
|
|
payload, signature, ok := strings.Cut(got, ".")
|
|
if !ok || payload != fixture.Payload || signature != expectedSignature {
|
|
t.Fatalf("Sign() parts = (%q, %q), want fixture payload and signature", payload, signature)
|
|
}
|
|
if strings.Contains(payload, "=") {
|
|
t.Fatal("Sign() emitted padded base64url")
|
|
}
|
|
}
|
|
|
|
func loadSessionFixture(t *testing.T) sessionFixture {
|
|
t.Helper()
|
|
path := filepath.Join("..", "..", "..", "contracts", "auth", "session-cookie-v1.json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v", err)
|
|
}
|
|
var fixture sessionFixture
|
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
|
t.Fatalf("decode fixture: %v", err)
|
|
}
|
|
return fixture
|
|
}
|
|
|
|
func TestParseValidatesAndNormalizesSession(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
session, err := Parse(fixture.CookieValue, fixture.Secret, time.Unix(150, 0))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if session.AuthMode != AuthModeAdmin || session.AccessToken != "access-token-1" || session.TokenType != "bearer" {
|
|
t.Fatalf("Parse() session = %#v", session)
|
|
}
|
|
if session.User.ID != "auth:customPC:1" || session.User.DisplayName != "张三" {
|
|
t.Fatalf("Parse() user = %#v", session.User)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsTamperExpiryAndMalformedWireValues(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
tampered := fixture.CookieValue[:len(fixture.CookieValue)-1] + "x"
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
now time.Time
|
|
}{
|
|
{name: "tampered", value: tampered, now: time.Unix(150, 0)},
|
|
{name: "exact expiry", value: fixture.CookieValue, now: time.Unix(200, 0)},
|
|
{name: "missing dot", value: fixture.Payload, now: time.Unix(150, 0)},
|
|
{name: "extra dot", value: fixture.CookieValue + ".extra", now: time.Unix(150, 0)},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if _, err := Parse(tt.value, fixture.Secret, tt.now); err == nil {
|
|
t.Fatal("Parse() error = nil, want rejection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseNormalizesLegacyAndMissingCollections(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
raw := []byte(`{"version":1,"issuedAt":100,"expiresAt":200,"user":{"id":"legacy","subject":"legacy","displayName":"Legacy","clientId":"customPC"}}`)
|
|
value, err := Sign(raw, fixture.Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := Parse(value, fixture.Secret, time.Unix(150, 0))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if session.AuthMode != AuthModeUser {
|
|
t.Fatalf("AuthMode = %q, want %q", session.AuthMode, AuthModeUser)
|
|
}
|
|
if session.User.Authorities == nil || len(session.User.Authorities) != 0 || session.User.Scope == nil || len(session.User.Scope) != 0 {
|
|
t.Fatalf("missing collections not normalized: %#v", session.User)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsInvalidProductionContract(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
tests := []string{
|
|
`{"version":2,"expiresAt":200,"user":{"id":"user","clientId":"customPC"}}`,
|
|
`{"version":1,"expiresAt":200,"user":{"id":"","clientId":"customPC"}}`,
|
|
`{"version":1,"expiresAt":200,"user":{"id":"user","clientId":""}}`,
|
|
`{"version":1,"expiresAt":"200","user":{"id":"user","clientId":"customPC"}}`,
|
|
`{"version":1,"expiresAt":200,"accessToken":7,"user":{"id":"user","clientId":"customPC"}}`,
|
|
}
|
|
for _, raw := range tests {
|
|
value, err := Sign([]byte(raw), fixture.Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Parse(value, fixture.Secret, time.Unix(150, 0)); err == nil {
|
|
t.Fatalf("Parse(%s) accepted invalid contract", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChunkAndReassembleUseLegacyCookieNamesAndLimits(t *testing.T) {
|
|
value := strings.Repeat("x", CookieChunkSize*2+1)
|
|
chunks, err := Chunk(SessionCookieName, value)
|
|
if err != nil {
|
|
t.Fatalf("Chunk() error = %v", err)
|
|
}
|
|
wantNames := []string{"zhinian_session", "zhinian_session.1", "zhinian_session.2"}
|
|
if len(chunks) != len(wantNames) {
|
|
t.Fatalf("len(Chunk()) = %d, want %d", len(chunks), len(wantNames))
|
|
}
|
|
values := make(map[string]string, len(chunks))
|
|
for index, chunk := range chunks {
|
|
if chunk.Name != wantNames[index] {
|
|
t.Errorf("chunk %d name = %q, want %q", index, chunk.Name, wantNames[index])
|
|
}
|
|
if len(chunk.Value) > CookieChunkSize {
|
|
t.Errorf("chunk %d length = %d", index, len(chunk.Value))
|
|
}
|
|
values[chunk.Name] = chunk.Value
|
|
}
|
|
got, ok := Reassemble(SessionCookieName, func(name string) (string, bool) {
|
|
value, found := values[name]
|
|
return value, found
|
|
})
|
|
if !ok || got != value {
|
|
t.Fatalf("Reassemble() = (%q, %v), want original", got, ok)
|
|
}
|
|
|
|
visited := make([]string, 0, CookieMaxChunks)
|
|
_, _ = Reassemble(SessionCookieName, func(name string) (string, bool) {
|
|
visited = append(visited, name)
|
|
return "x", true
|
|
})
|
|
if len(visited) != CookieMaxChunks || visited[len(visited)-1] != "zhinian_session.19" {
|
|
t.Fatalf("Reassemble visited %v", visited)
|
|
}
|
|
}
|
|
|
|
func TestCookieLifecycleMatchesSharedContract(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
cookie := fixture.Cookie
|
|
if SessionCookieName != cookie.Name || CookieChunkSize != cookie.ChunkSize || CookieMaxChunks != cookie.MaxChunks || CookieMaxValueLength != cookie.MaxValueLength {
|
|
t.Fatalf("Go constants do not match shared cookie contract: %#v", cookie)
|
|
}
|
|
if got := CookieNames(); !reflect.DeepEqual(got, cookie.ChunkNames) {
|
|
t.Fatalf("CookieNames() = %v, want %v", got, cookie.ChunkNames)
|
|
}
|
|
|
|
value := strings.Repeat(cookie.WriteExample.ValueCharacter, cookie.WriteExample.ValueLength)
|
|
chunks, err := Chunk(cookie.Name, value)
|
|
if err != nil {
|
|
t.Fatalf("Chunk() error = %v", err)
|
|
}
|
|
gotLengths := make([]int, len(chunks))
|
|
for index, chunk := range chunks {
|
|
gotLengths[index] = len(chunk.Value)
|
|
}
|
|
if !reflect.DeepEqual(gotLengths, cookie.WriteExample.ChunkLengths) {
|
|
t.Fatalf("chunk lengths = %v, want %v", gotLengths, cookie.WriteExample.ChunkLengths)
|
|
}
|
|
|
|
expires := time.Unix(cookie.WriteExample.ExpiresAtUnix, 0).UTC()
|
|
writes, err := SetSessionCookies(value, expires, cookie.Attributes.ProductionSecure)
|
|
if err != nil {
|
|
t.Fatalf("SetSessionCookies() error = %v", err)
|
|
}
|
|
if len(writes) != cookie.MaxChunks {
|
|
t.Fatalf("len(SetSessionCookies()) = %d, want %d", len(writes), cookie.MaxChunks)
|
|
}
|
|
for index, write := range writes {
|
|
if write.Name != cookie.ChunkNames[index] {
|
|
t.Errorf("write %d name = %q, want %q", index, write.Name, cookie.ChunkNames[index])
|
|
}
|
|
assertCookieAttributes(t, write.Attributes, cookie, index < len(chunks), expires)
|
|
if index >= len(chunks) && write.Value != cookie.Clear.Value {
|
|
t.Errorf("stale clear %d value = %q, want %q", index, write.Value, cookie.Clear.Value)
|
|
}
|
|
}
|
|
|
|
clears := ClearSessionCookies(cookie.Attributes.ProductionSecure)
|
|
if len(clears) != cookie.MaxChunks {
|
|
t.Fatalf("len(ClearSessionCookies()) = %d, want %d", len(clears), cookie.MaxChunks)
|
|
}
|
|
for index, clear := range clears {
|
|
if clear.Name != cookie.ChunkNames[index] || clear.Value != cookie.Clear.Value {
|
|
t.Errorf("clear %d = %#v", index, clear)
|
|
}
|
|
assertCookieAttributes(t, clear.Attributes, cookie, false, expires)
|
|
}
|
|
}
|
|
|
|
func TestChunkRejectsValuesBeyondSharedReadCeiling(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
_, err := Chunk(fixture.Cookie.Name, strings.Repeat("x", fixture.Cookie.MaxValueLength+1))
|
|
if !errors.Is(err, ErrSessionTooLarge) {
|
|
t.Fatalf("Chunk() error = %v, want ErrSessionTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestSecureCookieResolutionMatchesSharedContract(t *testing.T) {
|
|
fixture := loadSessionFixture(t)
|
|
for _, testCase := range fixture.Cookie.SecureResolutionCases {
|
|
t.Run(testCase.Name, func(t *testing.T) {
|
|
got := ResolveSecureCookie(testCase.Explicit, testCase.PublicBaseURL, testCase.RequestURL)
|
|
if got != testCase.Expected {
|
|
t.Fatalf("ResolveSecureCookie() = %v, want %v", got, testCase.Expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertCookieAttributes(t *testing.T, got CookieAttributes, contract sessionCookieContract, write bool, expires time.Time) {
|
|
t.Helper()
|
|
if got.HTTPOnly != contract.Attributes.HTTPOnly || got.SameSite != contract.Attributes.SameSite || got.Secure != contract.Attributes.ProductionSecure || got.Path != contract.Attributes.Path {
|
|
t.Errorf("attributes = %#v, want shared base attributes", got)
|
|
}
|
|
if write {
|
|
if got.Expires == nil || !got.Expires.Equal(expires) || got.MaxAgeSeconds != nil {
|
|
t.Errorf("write attributes = %#v, want Expires=%v and no Max-Age", got, expires)
|
|
}
|
|
return
|
|
}
|
|
if got.Expires != nil || got.MaxAgeSeconds == nil || *got.MaxAgeSeconds != contract.Clear.MaxAgeSeconds {
|
|
t.Errorf("clear attributes = %#v, want Max-Age=%d and no Expires", got, contract.Clear.MaxAgeSeconds)
|
|
}
|
|
}
|