package auth import ( "bytes" "compress/flate" "crypto" "crypto/ecdsa" "crypto/rsa" "crypto/sha1" "crypto/sha256" "crypto/sha512" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/xml" "errors" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/beevik/etree" "github.com/crewjam/saml" "github.com/gofiber/fiber/v2" dsig "github.com/russellhaering/goxmldsig" "github.com/valyala/fasthttp/fasthttpadaptor" "wucher/internal/config" "wucher/internal/service" ) type Handler struct { cfg config.Config auth *service.AuthService } func NewHandler(cfg config.Config, authSvc *service.AuthService) *Handler { return &Handler{cfg: cfg, auth: authSvc} } func (h *Handler) HandleSAMLCallback(c *fiber.Ctx) error { if SAMLMiddleware == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"}) } req, err := toHTTPRequest(c) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) } if err := req.ParseForm(); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid form payload"}) } assertion, err := SAMLMiddleware.ServiceProvider.ParseResponse(req, []string{""}) if err != nil { resp := fiber.Map{"error": "invalid saml response"} if strings.EqualFold(h.cfg.AppEnv, "development") || strings.EqualFold(h.cfg.AppEnv, "local") { resp["detail"] = err.Error() var invalidRespErr *saml.InvalidResponseError if errors.As(err, &invalidRespErr) && invalidRespErr.PrivateErr != nil { resp["private_detail"] = invalidRespErr.PrivateErr.Error() } if raw := c.FormValue("SAMLResponse"); raw != "" { if statusCode, statusMessage := samlResponseStatus(raw); statusCode != "" || statusMessage != "" { resp["status_code"] = statusCode resp["status_message"] = statusMessage } } } return c.Status(fiber.StatusUnauthorized).JSON(resp) } nameID := "" email := "" givenName := "" surname := "" displayName := "" if assertion.Subject != nil { nameID = assertion.Subject.NameID.Value } for _, stmt := range assertion.AttributeStatements { for _, attr := range stmt.Attributes { if len(attr.Values) == 0 { continue } val := attr.Values[0].Value switch attr.Name { case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "emailaddress": email = val case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "givenname": givenName = val case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "surname": surname = val case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name": displayName = val } } } if displayName == "" { displayName = givenName + " " + surname } now := time.Now().UTC() session := UserSession{ UserID: nameID, MicrosoftNameID: nameID, Email: email, Name: displayName, LoggedInAt: now, ExpiresAt: now.Add(time.Duration(h.cfg.SessionTTLHour) * time.Hour), } token, err := CreateSession(session) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create session failed"}) } if h.auth != nil { claims := map[string]any{ "sub": nameID, "email": email, "name": displayName, "oid": nameID, "exp": time.Now().UTC().Add(5 * time.Minute).Unix(), } identity, err := h.auth.ExchangeMicrosoftToken(c.UserContext(), "saml_access", "saml_id", claims) if err == nil && identity != nil { user, userErr := h.auth.GetUserByID(c.UserContext(), identity.UserID) if userErr == nil && user != nil { tokens, tokenErr := h.auth.IssueTokensWithClientForProviderAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), "microsoft", readDeviceTypeHint(c), ) if tokenErr == nil { setAuthCookies(c, h.auth, tokens) } } } } c.Cookie(&fiber.Cookie{ Name: "session_token", Value: token, HTTPOnly: true, Secure: h.cfg.CookieSecure, SameSite: "lax", Path: "/", Domain: h.cfg.CookieDomain, Expires: session.ExpiresAt, }) return c.Redirect(h.cfg.FrontendURL+"/dashboard", fiber.StatusFound) } func (h *Handler) HandleSLO(c *fiber.Ctx) error { return h.HandleSAMLLogoutRequest(c) } func (h *Handler) HandleSAMLLogoutRequest(c *fiber.Ctx) error { if SAMLMiddleware == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"}) } log.Printf("saml logout request: has_saml_request=%t has_saml_response=%t method=%s", formOrQuery(c, "SAMLRequest") != "", formOrQuery(c, "SAMLResponse") != "", c.Method()) rawReq := formOrQuery(c, "SAMLRequest") if rawReq == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLRequest"}) } xmlBytes, err := decodeSAMLMessage(rawReq) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLRequest encoding"}) } if c.Method() == fiber.MethodGet { if err := validateRedirectSAMLSignature(c); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"}) } } else { if err := validateLogoutRequestSignature(xmlBytes); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"}) } } var logoutReq saml.LogoutRequest if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutRequest XML"}) } if err := validateSAMLMessageIssuer(logoutReq.Issuer.Value); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest issuer"}) } nameID := strings.TrimSpace(logoutReq.NameID.Value) h.cleanupUserAuthSession(c, nameID) log.Printf("saml logout request processed: name_id_present=%t", nameID != "") relayState := formOrQuery(c, "RelayState") redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutResponse(logoutReq.ID, relayState) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build LogoutResponse"}) } return c.Redirect(redirectURL.String(), fiber.StatusFound) } func (h *Handler) HandleSAMLLogoutResponse(c *fiber.Ctx) error { if SAMLMiddleware == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"}) } rawQuery := c.Context().QueryArgs().String() hasSAMLResponse := formOrQuery(c, "SAMLResponse") != "" hasRelayState := formOrQuery(c, "RelayState") != "" hasSigAlg := formOrQuery(c, "SigAlg") != "" hasSignature := formOrQuery(c, "Signature") != "" sigAlg := formOrQuery(c, "SigAlg") log.Printf("saml logout response: method=%s path=%s hasSAMLResponse=%t hasRelayState=%t hasSigAlg=%t hasSignature=%t sigAlg=%q rawQueryLen=%d", c.Method(), c.Path(), hasSAMLResponse, hasRelayState, hasSigAlg, hasSignature, sigAlg, len(rawQuery)) rawResp := formOrQuery(c, "SAMLResponse") if rawResp == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLResponse"}) } if c.Method() == fiber.MethodGet && hasSigAlg && hasSignature { if err := validateRedirectSAMLSignatureRaw(rawQuery, "SAMLResponse"); err != nil { log.Printf("saml logout response: verification_result=failed mode=redirect reason=%v", err) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"}) } log.Printf("saml logout response: verification_result=ok mode=redirect") } xmlBytes, err := decodeSAMLMessage(rawResp) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLResponse encoding"}) } if c.Method() == fiber.MethodGet && !(hasSigAlg && hasSignature) { if hasSigAlg != hasSignature { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"}) } if !hasXMLSignature(xmlBytes) { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"}) } if err := validateLogoutResponseSignature(xmlBytes); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"}) } } else if c.Method() != fiber.MethodGet { if err := validateLogoutResponseSignature(xmlBytes); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"}) } } var logoutResp saml.LogoutResponse if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutResponse XML"}) } if err := validateSAMLMessageIssuer(logoutResp.Issuer.Value); err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse issuer"}) } if strings.TrimSpace(logoutResp.Status.StatusCode.Value) != saml.StatusSuccess { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse status"}) } h.cleanupUserAuthSession(c, "") return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound) } func (h *Handler) HandleMetadata(c *fiber.Ctx) error { if SAMLMiddleware == nil { return c.Status(fiber.StatusInternalServerError).SendString("saml is not initialized") } buf, err := xml.MarshalIndent(SAMLMiddleware.ServiceProvider.Metadata(), "", " ") if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("failed to marshal metadata") } c.Set(fiber.HeaderContentType, "application/samlmetadata+xml") return c.Send(buf) } func (h *Handler) HandleLogin(c *fiber.Ctx) error { if SAMLMiddleware == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"}) } redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectAuthenticationRequest("") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build authn request"}) } if wantsJSON(c) { return c.Status(fiber.StatusOK).JSON(fiber.Map{ "data": fiber.Map{ "type": "auth_microsoft_url", "attributes": fiber.Map{ "url": redirectURL.String(), "redirect_url": redirectURL.String(), }, }, }) } return c.Redirect(redirectURL.String(), fiber.StatusFound) } func (h *Handler) HandleLogout(c *fiber.Ctx) error { return h.HandleBrowserLocalLogout(c) } func (h *Handler) HandleBrowserLocalLogout(c *fiber.Ctx) error { log.Printf("browser local logout: method=%s", c.Method()) var nameID string if token := strings.TrimSpace(c.Cookies("session_token")); token != "" { if sess, err := GetSession(token); err == nil { nameID = strings.TrimSpace(sess.MicrosoftNameID) } } h.cleanupUserAuthSession(c, nameID) if nameID != "" && SAMLMiddleware != nil { redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutRequest(nameID, "") if err == nil { return c.Redirect(redirectURL.String(), fiber.StatusFound) } } return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound) } func toHTTPRequest(c *fiber.Ctx) (*http.Request, error) { r := new(http.Request) if err := fasthttpadaptor.ConvertRequest(c.Context(), r, true); err != nil { return nil, err } return r, nil } func validateLogoutRequestSignature(xmlBytes []byte) error { certs, err := getIDPCertificates() if err != nil { return err } certStore := dsig.MemoryX509CertificateStore{Roots: certs} validator := dsig.NewDefaultValidationContext(&certStore) doc := etree.NewDocument() if err := doc.ReadFromBytes(xmlBytes); err != nil { return fmt.Errorf("parse xml: %w", err) } root := doc.Root() if root == nil { return fmt.Errorf("empty xml") } _, err = validator.Validate(root) return err } func validateRedirectSAMLSignature(c *fiber.Ctx) error { rawQuery := c.Context().QueryArgs().String() return validateRedirectSAMLSignatureRaw(rawQuery, "") } func validateRedirectSAMLSignatureRaw(rawQuery, expectedMessageKey string) error { params, err := url.ParseQuery(rawQuery) if err != nil { return fmt.Errorf("invalid query: %w", err) } sigAlg := params.Get("SigAlg") signatureB64 := params.Get("Signature") if sigAlg == "" || signatureB64 == "" { return fmt.Errorf("missing SigAlg or Signature") } signedPayload, err := buildRedirectSignaturePayload(rawQuery, expectedMessageKey) if err != nil { return err } log.Printf("saml redirect signature: canonicalLen=%d", len(signedPayload)) signature, err := base64.StdEncoding.DecodeString(signatureB64) if err != nil { return fmt.Errorf("invalid signature encoding: %w", err) } certs, err := getIDPCertificates() if err != nil { return err } for _, cert := range certs { log.Printf("saml redirect signature: certThumbprint=%s", certificateThumbprintSHA1(cert)) if err := verifyRedirectSignature(cert.PublicKey, sigAlg, signedPayload, signature); err == nil { return nil } } return fmt.Errorf("signature verification failed") } func buildRedirectSignaturePayload(rawQuery, expectedMessageKey string) ([]byte, error) { var samlReq, samlResp, relay, sigAlg string for _, part := range strings.Split(rawQuery, "&") { if part == "" { continue } key, val, found := strings.Cut(part, "=") if !found { continue } switch key { case "SAMLRequest": if samlReq == "" { samlReq = val } case "SAMLResponse": if samlResp == "" { samlResp = val } case "RelayState": if relay == "" { relay = val } case "SigAlg": if sigAlg == "" { sigAlg = val } } } msgKey := "SAMLRequest" msgVal := samlReq if msgVal == "" { msgKey = "SAMLResponse" msgVal = samlResp } if expectedMessageKey != "" && msgKey != expectedMessageKey { return nil, fmt.Errorf("missing %s", expectedMessageKey) } if msgVal == "" || sigAlg == "" { return nil, fmt.Errorf("missing SAML payload or SigAlg") } payload := msgKey + "=" + msgVal if relay != "" { payload += "&RelayState=" + relay } payload += "&SigAlg=" + sigAlg return []byte(payload), nil } func validateLogoutResponseSignature(xmlBytes []byte) error { return validateLogoutRequestSignature(xmlBytes) } func hasXMLSignature(xmlBytes []byte) bool { doc := etree.NewDocument() if err := doc.ReadFromBytes(xmlBytes); err != nil { return false } root := doc.Root() if root == nil { return false } for _, el := range root.FindElements(".//*") { if strings.EqualFold(el.Tag, "Signature") || strings.HasSuffix(el.Tag, ":Signature") { return true } } return false } func certificateThumbprintSHA1(cert *x509.Certificate) string { if cert == nil { return "" } sum := sha1.Sum(cert.Raw) return strings.ToUpper(hex.EncodeToString(sum[:])) } func validateSAMLMessageIssuer(issuer string) error { issuer = strings.TrimSpace(issuer) if issuer == "" { return fmt.Errorf("missing issuer") } if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil { return fmt.Errorf("missing idp metadata") } expected := strings.TrimSpace(SAMLMiddleware.ServiceProvider.IDPMetadata.EntityID) if expected == "" { return fmt.Errorf("missing expected issuer") } if issuer != expected { return fmt.Errorf("unexpected issuer") } return nil } func (h *Handler) cleanupUserAuthSession(c *fiber.Ctx, nameID string) { if strings.TrimSpace(nameID) != "" { _ = DeleteSessionByNameID(nameID) } if token := strings.TrimSpace(c.Cookies("session_token")); token != "" { _ = DeleteSession(token) } clearSessionCookie(c, h.cfg.CookieDomain, h.cfg.CookieSecure) if h.auth == nil { return } accessToken := strings.TrimSpace(c.Cookies(h.auth.AccessCookieName())) refreshToken := strings.TrimSpace(c.Cookies(h.auth.RefreshCookieName())) if accessToken != "" { if userID, err := h.auth.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 { _ = h.auth.RevokeAllUserSessions(c.UserContext(), userID) log.Printf("logout cleanup: revoked app sessions user_id=%x", userID) } } if refreshToken != "" { _ = h.auth.RevokeRefreshToken(c.UserContext(), refreshToken) } clearAuthCookies(c, h.auth) } func clearSessionCookie(c *fiber.Ctx, domain string, secure bool) { c.Cookie(&fiber.Cookie{ Name: "session_token", Value: "", Path: "/", Domain: domain, HTTPOnly: true, Secure: secure, SameSite: "lax", MaxAge: -1, Expires: time.Unix(0, 0), }) } func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) { if svc == nil { return } c.Cookie(&fiber.Cookie{ Name: svc.AccessCookieName(), Value: "", HTTPOnly: true, Secure: svc.CookieSecure(), SameSite: parseSameSite(svc.CookieSameSite()), Domain: svc.CookieDomain(), Path: "/", MaxAge: -1, }) c.Cookie(&fiber.Cookie{ Name: svc.RefreshCookieName(), Value: "", HTTPOnly: true, Secure: svc.CookieSecure(), SameSite: parseSameSite(svc.CookieSameSite()), Domain: svc.CookieDomain(), Path: "/api/v1/auth/refresh", MaxAge: -1, }) } func (h *Handler) logoutRedirectURL() string { base := strings.TrimRight(strings.TrimSpace(h.cfg.FrontendURL), "/") if base == "" { return "/signin?loggedOut=true" } return base + "/signin?loggedOut=true" } func verifyRedirectSignature(pub any, sigAlg string, payload, signature []byte) error { switch sigAlg { case "http://www.w3.org/2000/09/xmldsig#rsa-sha1": sum := sha1.Sum(payload) key, ok := pub.(*rsa.PublicKey) if !ok { return fmt.Errorf("unexpected key type %T", pub) } return rsa.VerifyPKCS1v15(key, crypto.SHA1, sum[:], signature) case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": sum := sha256.Sum256(payload) key, ok := pub.(*rsa.PublicKey) if !ok { return fmt.Errorf("unexpected key type %T", pub) } return rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384": sum := sha512.Sum384(payload) key, ok := pub.(*rsa.PublicKey) if !ok { return fmt.Errorf("unexpected key type %T", pub) } return rsa.VerifyPKCS1v15(key, crypto.SHA384, sum[:], signature) case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512": sum := sha512.Sum512(payload) key, ok := pub.(*rsa.PublicKey) if !ok { return fmt.Errorf("unexpected key type %T", pub) } return rsa.VerifyPKCS1v15(key, crypto.SHA512, sum[:], signature) case "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256": sum := sha256.Sum256(payload) key, ok := pub.(*ecdsa.PublicKey) if !ok { return fmt.Errorf("unexpected key type %T", pub) } if ecdsa.VerifyASN1(key, sum[:], signature) { return nil } return fmt.Errorf("ecdsa verify failed") default: return fmt.Errorf("unsupported SigAlg %q", sigAlg) } } func formOrQuery(c *fiber.Ctx, key string) string { if v := strings.TrimSpace(c.FormValue(key)); v != "" { return v } return strings.TrimSpace(c.Query(key)) } func decodeSAMLMessage(raw string) ([]byte, error) { decoded, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, err } if bytes.Contains(decoded, []byte("<")) { return decoded, nil } reader := flate.NewReader(bytes.NewReader(decoded)) defer reader.Close() var out bytes.Buffer if _, err := out.ReadFrom(reader); err != nil { return nil, err } return out.Bytes(), nil } func samlResponseStatus(raw string) (string, string) { decoded, err := base64.StdEncoding.DecodeString(raw) if err != nil { return "", "" } var resp saml.Response if err := xml.Unmarshal(decoded, &resp); err != nil { return "", "" } if resp.Status.StatusCode.Value == "" && resp.Status.StatusMessage == nil { return "", "" } statusMessage := "" if resp.Status.StatusMessage != nil { statusMessage = strings.TrimSpace(resp.Status.StatusMessage.Value) } return resp.Status.StatusCode.Value, statusMessage } func readDeviceTypeHint(c *fiber.Ctx) string { return strings.TrimSpace(c.Get("X-Device-Type")) } func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) { if svc == nil { return } refreshTTL := tokens.RefreshTTL if refreshTTL <= 0 { refreshTTL = svc.RefreshTTL() } c.Cookie(&fiber.Cookie{ Name: svc.AccessCookieName(), Value: tokens.AccessToken, HTTPOnly: true, Secure: svc.CookieSecure(), SameSite: parseSameSite(svc.CookieSameSite()), Domain: svc.CookieDomain(), Path: "/", MaxAge: int(svc.AccessTTL().Seconds()), }) c.Cookie(&fiber.Cookie{ Name: svc.RefreshCookieName(), Value: tokens.RefreshToken, HTTPOnly: true, Secure: svc.CookieSecure(), SameSite: parseSameSite(svc.CookieSameSite()), Domain: svc.CookieDomain(), Path: "/api/v1/auth/refresh", MaxAge: int(refreshTTL.Seconds()), }) } func parseSameSite(v string) string { switch strings.ToLower(strings.TrimSpace(v)) { case "none": return "none" case "strict": return "strict" default: return "lax" } } func wantsJSON(c *fiber.Ctx) bool { accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept))) return strings.Contains(accept, "application/json") || strings.Contains(accept, "application/vnd.api+json") }