// Modified in the independent hardening fork; see docs/hardening. package openidentity import ( "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "agent-desk/internal/pkg/enums" "github.com/gin-gonic/gin" ) const ( testSecret = "shared-identity-secret" testChannelID = "web-main" testUserID = "u_10001" ) func TestSignAndVerifyExternalIDSignature(t *testing.T) { now := time.Now() signature := SignExternalID(testSecret, testChannelID, testUserID, now) if len(signature) != 64 { t.Fatalf("signature = %q, want 64 hex chars", signature) } if strings.ToLower(signature) != signature { t.Fatalf("signature = %q, want lower-case hex", signature) } if !VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, now.Unix(), 24*time.Hour, now) { t.Fatal("expected a freshly produced signature to verify") } } func TestVerifyExternalIDSignatureRejects(t *testing.T) { now := time.Now() valid := SignExternalID(testSecret, testChannelID, testUserID, now) tests := []struct { name string secret string channelID string externalID string signature string issuedAt int64 }{ {name: "wrong secret", secret: "other-secret", channelID: testChannelID, externalID: testUserID, signature: valid, issuedAt: now.Unix()}, {name: "empty secret disables the feature", secret: "", channelID: testChannelID, externalID: testUserID, signature: valid, issuedAt: now.Unix()}, {name: "signature from another channel", secret: testSecret, channelID: "web-other", externalID: testUserID, signature: valid, issuedAt: now.Unix()}, {name: "signature for another visitor", secret: testSecret, channelID: testChannelID, externalID: "u_20002", signature: valid, issuedAt: now.Unix()}, {name: "truncated signature", secret: testSecret, channelID: testChannelID, externalID: testUserID, signature: valid[:len(valid)-2], issuedAt: now.Unix()}, {name: "single flipped nibble", secret: testSecret, channelID: testChannelID, externalID: testUserID, signature: flipLastChar(valid), issuedAt: now.Unix()}, {name: "not hex", secret: testSecret, channelID: testChannelID, externalID: testUserID, signature: strings.Repeat("z", 64), issuedAt: now.Unix()}, {name: "empty signature", secret: testSecret, channelID: testChannelID, externalID: testUserID, signature: "", issuedAt: now.Unix()}, {name: "empty external id", secret: testSecret, channelID: testChannelID, externalID: "", signature: valid, issuedAt: now.Unix()}, {name: "missing issued at", secret: testSecret, channelID: testChannelID, externalID: testUserID, signature: valid, issuedAt: 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if VerifyExternalIDSignature(tt.secret, tt.channelID, tt.externalID, tt.signature, tt.issuedAt, 24*time.Hour, now) { t.Fatal("expected signature to be rejected") } }) } } // The timestamp travels as its own field so the host does not have to encode it // inside the signature, but it is covered by the signed bytes, so re-dating a // captured signature breaks verification. func TestVerifyExternalIDSignatureRejectsRedatedSignature(t *testing.T) { now := time.Now() signature := SignExternalID(testSecret, testChannelID, testUserID, now) // Claim the signature was issued a minute ago. if VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, now.Add(-time.Minute).Unix(), 24*time.Hour, now) { t.Fatal("expected a re-dated signature to be rejected") } // Claim it was issued an hour from now to push it past the window. if VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, now.Add(time.Hour).Unix(), 24*time.Hour, now) { t.Fatal("expected a future-dated signature to be rejected") } } func TestVerifyExternalIDSignatureExpires(t *testing.T) { issuedAt := time.Now().Add(-25 * time.Hour) signature := SignExternalID(testSecret, testChannelID, testUserID, issuedAt) if VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, issuedAt.Unix(), 24*time.Hour, time.Now()) { t.Fatal("expected a signature older than the max age to be rejected") } if !VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, issuedAt.Unix(), 72*time.Hour, time.Now()) { t.Fatal("expected the same signature to be accepted with a longer max age") } } func TestVerifyExternalIDSignatureToleratesSmallClockSkew(t *testing.T) { issuedAt := time.Now().Add(2 * time.Minute) signature := SignExternalID(testSecret, testChannelID, testUserID, issuedAt) if !VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, issuedAt.Unix(), 24*time.Hour, time.Now()) { t.Fatal("expected a small forward clock skew to be tolerated") } } func TestVerifyExternalIDSignatureDefaultsMaxAge(t *testing.T) { issuedAt := time.Now().Add(-(DefaultIdentityMaxAge - time.Hour)) signature := SignExternalID(testSecret, testChannelID, testUserID, issuedAt) if !VerifyExternalIDSignature(testSecret, testChannelID, testUserID, signature, issuedAt.Unix(), 0, time.Now()) { t.Fatal("expected the default max age to apply when none is configured") } } // A plain embed passes an unsigned external ID. It must keep working, but the // visitor must land as an anonymous guest rather than as an identified user. func TestGetExternalUserUnsignedIDIsGuest(t *testing.T) { ctx, _ := newIdentityContext(map[string]string{ "X-External-Id": "guest_abc", "X-External-Name": "Ivan", }) user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}) if err != nil { t.Fatalf("unsigned embed must not be rejected: %v", err) } if user.ExternalSource != enums.ExternalSourceGuest { t.Fatalf("ExternalSource = %q, want %q", user.ExternalSource, enums.ExternalSourceGuest) } if user.ExternalID != "guest_abc" { t.Fatalf("ExternalID = %q, want guest_abc", user.ExternalID) } if user.ExternalName != "Ivan" { t.Fatalf("ExternalName = %q, want Ivan", user.ExternalName) } } func TestGetExternalUserSignedIDIsIdentified(t *testing.T) { ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now())) user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}) if err != nil { t.Fatalf("signed id must be accepted: %v", err) } if user.ExternalSource != enums.ExternalSourceUser { t.Fatalf("ExternalSource = %q, want %q", user.ExternalSource, enums.ExternalSourceUser) } if user.ExternalID != testUserID { t.Fatalf("ExternalID = %q, want %q", user.ExternalID, testUserID) } } // The core impersonation case: someone replays a valid signature against a // different visitor ID. func TestGetExternalUserReplayedSignatureDowngradesToGuest(t *testing.T) { ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, "attacker_1", time.Now())) ctx.Request.Header.Set("X-External-Id", "victim_999") user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}) if err != nil { t.Fatalf("unexpected error: %v", err) } if user.ExternalSource != enums.ExternalSourceGuest { t.Fatalf("ExternalSource = %q, want %q for a replayed signature", user.ExternalSource, enums.ExternalSourceGuest) } } // Without a configured secret nothing can ever be trusted, which is the safe // default for an existing deployment. func TestGetExternalUserWithoutConfiguredSecretIsGuest(t *testing.T) { ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now())) user, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID}) if err != nil { t.Fatalf("unexpected error: %v", err) } if user.ExternalSource != enums.ExternalSourceGuest { t.Fatalf("ExternalSource = %q, want guest when no secret is configured", user.ExternalSource) } } func TestGetExternalUserExpiredSignatureIsGuest(t *testing.T) { ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now().Add(-25*time.Hour))) user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, IdentityMaxAge: 24 * time.Hour, ChannelID: testChannelID}) if err != nil { t.Fatalf("unexpected error: %v", err) } if user.ExternalSource != enums.ExternalSourceGuest { t.Fatalf("ExternalSource = %q, want guest for an expired signature", user.ExternalSource) } } func TestGetExternalUserRequiresAnExternalID(t *testing.T) { ctx, _ := newIdentityContext(map[string]string{"X-Channel-Id": testChannelID}) if _, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}); err == nil { t.Fatal("expected an error when no external id is supplied") } } func TestGetExternalUserAcceptsIdentityFromQuery(t *testing.T) { now := time.Now() signature := SignExternalID(testSecret, testChannelID, testUserID, now) ctx, _ := newIdentityContext(nil) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/customer/session_exchange?externalId="+testUserID+ "&externalIdSignature="+signature+ "&externalIdSignedAt="+itoa(now.Unix()), nil) user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}) if err != nil { t.Fatalf("signed id from the query string must be accepted: %v", err) } if user.ExternalSource != enums.ExternalSourceUser { t.Fatalf("ExternalSource = %q, want %q", user.ExternalSource, enums.ExternalSourceUser) } } func TestGetExternalUserIgnoresGarbageIssuedAt(t *testing.T) { now := time.Now() ctx, _ := newIdentityContext(map[string]string{ "X-External-Id": testUserID, "X-External-Id-Signature": SignExternalID(testSecret, testChannelID, testUserID, now), "X-External-Id-Signed-At": "not-a-number", }) user, err := GetExternalUser(ctx, Secrets{IdentityHMAC: testSecret, ChannelID: testChannelID}) if err != nil { t.Fatalf("unexpected error: %v", err) } if user.ExternalSource != enums.ExternalSourceGuest { t.Fatalf("ExternalSource = %q, want guest for an unparsable timestamp", user.ExternalSource) } } func signedIdentityHeaders(secret, channelID, externalID string, issuedAt time.Time) map[string]string { return map[string]string{ "X-External-Id": externalID, "X-External-Id-Signature": SignExternalID(secret, channelID, externalID, issuedAt), "X-External-Id-Signed-At": itoa(issuedAt.UTC().Unix()), "X-External-Name": "Ivan", } } func newIdentityContext(headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodPost, "/api/customer/session_exchange", nil) for key, value := range headers { ctx.Request.Header.Set(key, value) } return ctx, recorder } func flipLastChar(signature string) string { last := signature[len(signature)-1] replacement := byte('0') if last == '0' { replacement = '1' } return signature[:len(signature)-1] + string(replacement) } func itoa(value int64) string { return strconv.FormatInt(value, 10) } func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) { for _, signature := range []string{"", "invalid"} { ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature}) if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil { t.Fatal("signed-only policy accepted an unsigned or invalid identity") } } ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now())) user, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}) if err != nil || user.IdentityScope != IdentityScope(testChannelID, "hmac") { t.Fatal("valid signed identity did not retain its HMAC scope") } } func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) { seen := map[string]bool{} for _, channel := range []string{"channel-one", "channel-two"} { for _, issuer := range []string{"guest", "hmac", "jwt"} { scope := IdentityScope(channel, issuer) if scope == "" || seen[scope] { t.Fatal("identity scopes collided") } seen[scope] = true } } if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" { t.Fatal("invalid scope inputs accepted") } }