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( `%s`, 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) } }