Files
fm_be/internal/auth/handler_logout_test.go
2026-07-16 22:16:45 +07:00

258 lines
8.2 KiB
Go

package auth
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"github.com/gofiber/fiber/v2"
"wucher/internal/config"
)
func TestHandleBrowserLocalLogout_RedirectAndClearSessionCookie(t *testing.T) {
prev := SAMLMiddleware
SAMLMiddleware = nil
t.Cleanup(func() { SAMLMiddleware = prev })
h := NewHandler(config.Config{
FrontendURL: "https://wfm-dev.mybit.co.id",
CookieDomain: ".mybit.co.id",
CookieSecure: true,
}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleBrowserLocalLogout)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
t.Fatalf("unexpected redirect: %s", got)
}
setCookie := strings.Join(resp.Header.Values("Set-Cookie"), "\n")
if !strings.Contains(setCookie, "session_token=") {
t.Fatalf("expected session_token clear cookie, got %q", setCookie)
}
if !strings.Contains(strings.ToLower(setCookie), "domain=.mybit.co.id") {
t.Fatalf("expected cookie domain .mybit.co.id, got %q", setCookie)
}
}
func TestBuildRedirectSignaturePayload_PreservesRawEncoding(t *testing.T) {
raw := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=ignored"
got, err := buildRedirectSignaturePayload(raw, "SAMLResponse")
if err != nil {
t.Fatalf("build payload: %v", err)
}
want := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256"
if string(got) != want {
t.Fatalf("payload mismatch\nwant: %s\ngot: %s", want, string(got))
}
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithRelayState(t *testing.T) {
runValidSAMLResponseRedirectTest(t, "relay-state-1", true)
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithoutRelayState(t *testing.T) {
runValidSAMLResponseRedirectTest(t, "", false)
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Invalid(t *testing.T) {
priv, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
params := []string{
"SAMLResponse=" + url.QueryEscape(xmlB64),
"SigAlg=" + url.QueryEscape(sigAlg),
}
canonical := strings.Join(params, "&")
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
// Break signature on purpose
sig = strings.TrimRight(sig, "=") + "A"
path := "/api/v1/auth/microsoft/logout?" + canonical + "&Signature=" + url.QueryEscape(sig)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, path, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestHandleSAMLLogoutResponse_GetMissingSignature(t *testing.T) {
_, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
path := "/api/v1/auth/microsoft/logout?SAMLResponse=" + url.QueryEscape(xmlB64)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, path, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func runValidSAMLResponseRedirectTest(t *testing.T, relayState string, withRelay bool) {
t.Helper()
priv, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
parts := []string{"SAMLResponse=" + url.QueryEscape(xmlB64)}
if withRelay {
parts = append(parts, "RelayState="+url.QueryEscape(relayState))
}
parts = append(parts, "SigAlg="+url.QueryEscape(sigAlg))
canonical := strings.Join(parts, "&")
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
rawQuery := canonical + "&Signature=" + url.QueryEscape(sig)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout?"+rawQuery, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
t.Fatalf("unexpected redirect: %s", got)
}
}
func signRedirectPayloadRSA256(t *testing.T, priv *rsa.PrivateKey, payload []byte) string {
t.Helper()
sum := sha256.Sum256(payload)
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, sum[:])
if err != nil {
t.Fatalf("sign payload: %v", err)
}
return base64.StdEncoding.EncodeToString(sig)
}
func testSigningCert(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen key: %v", err)
}
serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
tpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "test-idp"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return priv, cert
}
func setTestSAMLMiddlewareWithCert(t *testing.T, cert *x509.Certificate, entityID string) {
t.Helper()
prev := SAMLMiddleware
SAMLMiddleware = &samlsp.Middleware{}
SAMLMiddleware.ServiceProvider.IDPMetadata = &saml.EntityDescriptor{
EntityID: entityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
KeyDescriptors: []saml.KeyDescriptor{
{
KeyInfo: dsigKeyInfoFromCert(cert),
},
},
},
},
},
},
}
t.Cleanup(func() { SAMLMiddleware = prev })
}
func dsigKeyInfoFromCert(cert *x509.Certificate) saml.KeyInfo {
if cert == nil {
return saml.KeyInfo{}
}
return saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
},
},
}
}
func testLogoutResponseXML(issuer string) string {
return fmt.Sprintf(
`<LogoutResponse xmlns="urn:oasis:names:tc:SAML:2.0:protocol" ID="_id123" Version="2.0" IssueInstant="2026-05-28T00:00:00Z"><Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">%s</Issuer><Status><StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></Status></LogoutResponse>`,
issuer,
)
}
func TestCertificateThumbprintSHA1(t *testing.T) {
_, cert := testSigningCert(t)
got := certificateThumbprintSHA1(cert)
if got == "" {
t.Fatal("thumbprint should not be empty")
}
sum := sha1.Sum(cert.Raw)
want := strings.ToUpper(hex.EncodeToString(sum[:]))
if got != want {
t.Fatalf("thumbprint mismatch: got=%s want=%s", got, want)
}
}