package handlers import ( "bytes" "context" "errors" "fmt" "log/slog" "net/url" "strconv" "strings" "github.com/gofiber/fiber/v2" "wucher/internal/domain/auth" "wucher/internal/domain/contact" "wucher/internal/service" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/apperrorsx" tzpkg "wucher/internal/shared/pkg/timezone" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/dto" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" "wucher/internal/transport/http/validators" ) type AuthHandler struct { svc *service.AuthService contactSvc contact.Service storage fileManagerDownloadURLStorage validate *validators.Validator } const ( pinVerificationHeader = "X-PIN-Verification-Token" deviceTypeHeader = "X-Device-Type" ) func NewAuthHandler(svc *service.AuthService) *AuthHandler { return &AuthHandler{ svc: svc, validate: validators.New(), } } func (h *AuthHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *AuthHandler { h.storage = storage return h } func (h *AuthHandler) WithContactService(contactSvc contact.Service) *AuthHandler { h.contactSvc = contactSvc return h } // Register godoc // @Summary Register user with email & password // @Description Creates user (public role: from AUTH_DEFAULT_ROLE, fallback: user) and sends verification link to email without issuing an auth session. // @Description Next: open verify link -> then login. // @Tags Auth // @Accept json // @Produce json // @Param request body dto.RegisterRequest true "JSON:API Register request" // @Success 201 {object} dto.UserResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/register [post] func (h *AuthHandler) Register(c *fiber.Ctx) error { if h.svc.RegisterDisabled() { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Registration disabled", Detail: "registration is disabled in this environment", }}) } var req dto.RegisterRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if strings.TrimSpace(req.Data.Attributes.Username) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "username is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"}, }}) } defaultRoleName := strings.TrimSpace(h.svc.DefaultRoleName()) if defaultRoleName == "" { defaultRoleName = "user" } roleID, err := h.svc.GetRoleIDByName(c.UserContext(), defaultRoleName) if err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register.GetRoleIDByName", err) } user, err := h.svc.RegisterWithEmail( c.UserContext(), req.Data.Attributes.Email, req.Data.Attributes.Username, req.Data.Attributes.FirstName, req.Data.Attributes.LastName, strings.TrimSpace(req.Data.Attributes.MobilePhone), req.Data.Attributes.Password, roleID, ) if err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register", err) } id, _ := uuidv7.BytesToString(user.ID) roleIDStr, _ := uuidv7.BytesToString(roleID) resp := jsonapi.Resource{ Type: "user", ID: id, Attributes: map[string]any{ "email": user.Email, "username": derefString(user.Username), "first_name": user.FirstName, "last_name": user.LastName, "role_id": roleIDStr, "role_ids": []string{roleIDStr}, "mobile_phone": user.MobilePhone, "timezone": tzpkg.WithDefault(user.Timezone), "registered": true, }, } return response.Write(c, fiber.StatusCreated, resp) } // Invite godoc // @Summary Invite user // @Description Creates user without password and sends set-password link to email. // @Tags Auth // @Accept json // @Produce json // @Param request body dto.InviteRequest true "JSON:API Invite request" // @Success 201 {object} dto.UserResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/invite [post] func (h *AuthHandler) Invite(c *fiber.Ctx) error { var req dto.InviteRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if strings.TrimSpace(req.Data.Attributes.Username) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "username is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"}, }}) } roleID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.RoleID)) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "role_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/role_id"}, }}) } roleIDs, roleErrs := parseRoleIDs(req.Data.Attributes.RoleIDs, "/data/attributes/role_ids") if len(roleErrs) > 0 { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, roleErrs) } roleIDs = normalizeRoleIDs(roleID, roleIDs) user, err := h.svc.InviteUserWithRoles( c.UserContext(), req.Data.Attributes.Email, req.Data.Attributes.Username, req.Data.Attributes.FirstName, req.Data.Attributes.LastName, strings.TrimSpace(req.Data.Attributes.MobilePhone), roleID, roleIDs, ) if err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Invite", err) } id, _ := uuidv7.BytesToString(user.ID) _, roles := authRoleResponseData(h.svc, c, user) roleIDStr, roleIDList := roleResponseIDFields(roles) resp := jsonapi.Resource{ Type: "user", ID: id, Attributes: map[string]any{ "email": user.Email, "username": derefString(user.Username), "first_name": user.FirstName, "last_name": user.LastName, "mobile_phone": user.MobilePhone, "role_id": roleIDStr, "role_ids": roleIDList, "timezone": tzpkg.WithDefault(user.Timezone), }, } return response.Write(c, fiber.StatusCreated, resp) } // SetPassword godoc // @Summary Set password from invite token // @Description Sets first password for invited user and marks email as verified. // @Tags Auth // @Accept json // @Produce json // @Param request body dto.SetPasswordRequest true "JSON:API Set password request" // @Success 200 {object} dto.WebAuthnResetSetupResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/set-password [post] func (h *AuthHandler) SetPassword(c *fiber.Ctx) error { var req dto.SetPasswordRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.SetPasswordWithInviteToken(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.Password); err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetPassword", err) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_set_password", Attributes: map[string]any{ "success": true, }, }) } // ResendSetPasswordInvite godoc // @Summary Resend set-password invite // @Description Triggers a new set-password invite email for an invited user by user_id. Response is generic for security. // @Tags Auth // @Accept json // @Produce json // @Param request body dto.ResendSetPasswordInviteRequest true "JSON:API resend set-password invite request" // @Success 200 {object} dto.AuthSessionsResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/invite/resend-set-password [post] func (h *AuthHandler) ResendSetPasswordInvite(c *fiber.Ctx) error { var req dto.ResendSetPasswordInviteRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } userID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.UserID)) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"}, }}) } h.svc.ResendInviteSetPassword(c.UserContext(), userID) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_resend_set_password", Attributes: map[string]any{ "message": "If allowed, a set-password invite email has been sent.", }, }) } // ForgotPassword godoc // @Summary Forgot password // @Description Always returns a generic success response. // @Tags Auth - Forgot Password // @Accept json // @Produce json // @Param request body dto.ForgotPasswordRequest true "JSON:API Forgot password request" // @Success 200 {object} dto.AuthSessionsResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/forgot-password [post] func (h *AuthHandler) ForgotPassword(c *fiber.Ctx) error { var req dto.ForgotPasswordRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } h.svc.ForgotPassword( c.UserContext(), req.Data.Attributes.Email, c.IP(), c.Get(fiber.HeaderUserAgent), ) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_forgot_password", Attributes: map[string]any{ "message": "If the account exists, a password reset link has been sent.", }, }) } // ResetPassword godoc // @Summary Reset password // @Description Resets password using a single-use reset token. // @Tags Auth - Forgot Password // @Accept json // @Produce json // @Param request body dto.ResetPasswordRequest true "JSON:API Reset password request" // @Success 200 {object} dto.AuthSessionDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/reset-password [post] func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error { var req dto.ResetPasswordRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if req.Data.Attributes.NewPassword != req.Data.Attributes.ConfirmPassword { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "confirm_password must match new_password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_password"}, }}) } if err := h.svc.ResetPassword(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.NewPassword); err != nil { if errors.Is(err, service.ErrInvalidResetLink) || errors.Is(err, service.ErrInvalidToken) { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid reset link", Detail: "This reset link is invalid or expired.", }}) } return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Reset password failed", Detail: "unable to reset password at this time", }}) } clearAuthCookies(c, h.svc) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_reset_password", Attributes: map[string]any{ "message": "Password has been reset successfully. Please log in again.", }, }) } // SetupSecurityPIN godoc // @Summary Setup security PIN // @Description Creates a 6-digit security PIN for sensitive actions. // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.SecurityPINSetupRequest true "JSON:API security PIN setup request" // @Success 200 {object} dto.SecurityPINSetupResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/setup [post] func (h *AuthHandler) SetupSecurityPIN(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { e := apperrorsx.New(apperrorsx.ErrUserUnauthorized) e.Message = "missing auth context" return apperrorsx.Write(c, e) } var req dto.SecurityPINSetupRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if req.Data.Attributes.PIN != req.Data.Attributes.ConfirmPIN { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "confirm_pin must match pin", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"}, }}) } if err := h.svc.SetSecurityPIN(c.UserContext(), userID, req.Data.Attributes.PIN); err != nil { switch { case errors.Is(err, service.ErrSecurityPINAlreadySet): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet)) case errors.Is(err, service.ErrInvalidSecurityPIN): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "pin must be exactly 6 digits", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/pin"}, }}) default: return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetupSecurityPIN", err) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_pin_setup", Attributes: map[string]any{ "configured": true, }, }) } func derefString(v *string) string { if v == nil { return "" } return strings.TrimSpace(*v) } // VerifySecurityPIN godoc // @Summary Verify security PIN for sensitive action // @Description Verifies PIN and returns a short-lived one-time action token. // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.SecurityPINVerifyRequest true "JSON:API security PIN verify request" // @Success 200 {object} dto.SecurityPINVerifyResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/verify [post] func (h *AuthHandler) VerifySecurityPIN(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { e := apperrorsx.New(apperrorsx.ErrUserUnauthorized) e.Message = "missing auth context" return apperrorsx.Write(c, e) } var req dto.SecurityPINVerifyRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } sessionID := "" if accessToken := readValidAccessToken(c, h.svc); accessToken != "" { sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err != nil { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid)) } sessionID = sid } token, err := h.svc.VerifySecurityPINForActionInSession(c.UserContext(), userID, req.Data.Attributes.PIN, req.Data.Attributes.Action, sessionID) if err != nil { switch { case errors.Is(err, service.ErrSecurityPINNotSet): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet)) case errors.Is(err, service.ErrSecurityPINLocked): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrSecurityPINAttemptLimit): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrSecurityPINResetRequired): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrInvalidSecurityPIN): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect)) default: return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.VerifySecurityPIN", err) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_pin_verify", Attributes: map[string]any{ "verified": true, "action_token": token, "action_token_ttl_ms": h.svc.SecurityPINTokenTTL().Milliseconds(), }, }) } // ChangeSecurityPIN godoc // @Summary Change security PIN // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.SecurityPINChangeRequest true "JSON:API security PIN change request" // @Success 200 {object} dto.AuthSessionsResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/change [post] func (h *AuthHandler) ChangeSecurityPIN(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { e := apperrorsx.New(apperrorsx.ErrUserUnauthorized) e.Message = "missing auth context" return apperrorsx.Write(c, e) } var req dto.SecurityPINChangeRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "confirm_pin must match new_pin", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"}, }}) } if err := h.svc.ChangeSecurityPIN(c.UserContext(), userID, req.Data.Attributes.CurrentPIN, req.Data.Attributes.NewPIN); err != nil { switch { case errors.Is(err, service.ErrSecurityPINNotSet): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet)) case errors.Is(err, service.ErrSecurityPINLocked): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrSecurityPINAttemptLimit): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrSecurityPINResetRequired): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked)) case errors.Is(err, service.ErrInvalidSecurityPIN): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect)) default: return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.ChangeSecurityPIN", err) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_pin_change", Attributes: map[string]any{ "updated": true, }, }) } // ForgotSecurityPIN godoc // @Summary Forgot security PIN // @Description Always returns a generic success response. // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.ForgotSecurityPINRequest true "JSON:API forgot security PIN request" // @Success 200 {object} dto.AuthSessionDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/forgot [post] func (h *AuthHandler) ForgotSecurityPIN(c *fiber.Ctx) error { var req dto.ForgotSecurityPINRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } h.svc.ForgotSecurityPIN( c.UserContext(), req.Data.Attributes.Email, c.IP(), c.Get(fiber.HeaderUserAgent), ) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_forgot_pin", Attributes: map[string]any{ "message": "If the account exists, a security PIN reset link has been sent.", }, }) } // RequestSecurityPINReset godoc // @Summary Request security PIN reset email for blocked PIN // @Description Verifies current account password, then sends PIN reset email to currently logged-in user when PIN is blocked. // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.RequestSecurityPINRequest true "JSON:API request security PIN reset payload" // @Success 200 {object} dto.RequestSecurityPINResetResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/request-reset [post] func (h *AuthHandler) RequestSecurityPINReset(c *fiber.Ctx) error { var req dto.RequestSecurityPINRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { e := apperrorsx.New(apperrorsx.ErrUserUnauthorized) e.Message = "missing auth context" return apperrorsx.Write(c, e) } method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method)) if method == "" { method = "password" } switch method { case "password": if strings.TrimSpace(req.Data.Attributes.Password) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "password is required when method is password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"}, }}) } case "microsoft": if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "reauth_token is required when method is microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"}, }}) } default: return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: password, microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) } if err := h.svc.RequestSecurityPINReset(c.UserContext(), userID, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, c.IP(), c.Get(fiber.HeaderUserAgent)); err != nil { switch { case errors.Is(err, service.ErrInvalidPINResetPassword): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized)) case errors.Is(err, service.ErrInvalidSSOReauth): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid)) case errors.Is(err, service.ErrInvalidReauthMethod): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: password, microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) case errors.Is(err, service.ErrSecurityPINNotBlocked): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotBlocked)) case errors.Is(err, service.ErrSecurityPINResetRatelimit): return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINAttemptLimitReached)) default: return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Reset request failed", Detail: "unable to send reset email at this time", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_pin_request_reset", Attributes: map[string]any{ "message": "If allowed, a security PIN reset link has been sent to your email.", }, }) } // ResetSecurityPIN godoc // @Summary Reset security PIN // @Description Resets security PIN using a re-auth method. For method=password, send the single-use reset token from the email link plus password. For method=microsoft, send only reauth_token from Microsoft re-auth. // @Tags Auth - PIN // @Accept json // @Produce json // @Param request body dto.ResetSecurityPINRequest true "JSON:API reset security PIN request" // @Success 200 {object} dto.AuthSessionsResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/pin/reset [post] func (h *AuthHandler) ResetSecurityPIN(c *fiber.Ctx) error { var req dto.ResetSecurityPINRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "confirm_pin must match new_pin", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"}, }}) } method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method)) if method == "" { method = "password" } switch method { case "password": if strings.TrimSpace(req.Data.Attributes.Token) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "token is required when method is password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/token"}, }}) } if strings.TrimSpace(req.Data.Attributes.Password) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "password is required when method is password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"}, }}) } case "microsoft": if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "reauth_token is required when method is microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"}, }}) } default: return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: password, microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) } if err := h.svc.ResetSecurityPIN(c.UserContext(), req.Data.Attributes.Token, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, req.Data.Attributes.NewPIN); err != nil { if errors.Is(err, service.ErrInvalidResetLink) { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrInvalidRequest)) } if errors.Is(err, service.ErrInvalidPINResetPassword) { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized)) } if errors.Is(err, service.ErrInvalidSSOReauth) { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid)) } if errors.Is(err, service.ErrInvalidReauthMethod) { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: password, microsoft", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) } if errors.Is(err, service.ErrInvalidSecurityPIN) { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "new_pin must be exactly 6 digits", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/new_pin"}, }}) } return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Reset security PIN failed", Detail: "unable to reset security PIN at this time", }}) } attrs := map[string]any{ "message": "Security PIN has been reset successfully.", "reset_success": true, } if redirectURL := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirectURL != "" { attrs["redirect_url"] = redirectURL } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_reset_pin", Attributes: attrs, }) } // Login godoc // @Summary Login with email & password // @Description If TOTP enabled, returns 202 + challenge_token (use /auth/totp/verify). // @Description If TOTP is not enabled, returns 202 + email OTP challenge_token (use /auth/email-otp/send then /auth/email-otp/verify). // @Tags Auth // @Accept json // @Produce json // @Param request body dto.LoginRequest true "JSON:API Login request" // @Success 200 {object} dto.AuthCurrentUserResponse // @Success 202 {object} dto.LoginTOTPRequiredResponse // @Success 202 {object} dto.LoginEmailOTPRequiredResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/login [post] func (h *AuthHandler) Login(c *fiber.Ctx) error { var req dto.LoginRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } user, err := h.svc.LoginWithEmailPassword(c.UserContext(), req.Data.Attributes.Email, req.Data.Attributes.Password) if err != nil { return apperrorsx.Write(c, apperrorsx.Translate(apperrorsx.ModuleAuth, err)) } totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID) webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID) linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID) pinConfigured := len(user.SecurityPINHash) > 0 if totpEnabled { token, err := h.svc.CreateTOTPChallenge(c.UserContext(), user.ID) if err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.CreateTOTPChallenge", err) } attrs := map[string]any{ "requires_totp": true, "challenge_token": token, "totp_enabled": true, "email_otp_enabled": false, "webauthn_configured": webauthnConfigured, "linked_sso": linkedSSO, "pin_configured": pinConfigured, } // When email OTP is enabled globally, mint a parallel email-OTP challenge // so the user can fall back to email verification without re-submitting credentials. if h.svc.IsLoginEmailOTPEnabled(c.UserContext()) { emailToken, emailErr := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID) if emailErr == nil { attrs["email_otp_enabled"] = true attrs["email_otp_challenge_token"] = emailToken attrs["email_otp_resend_cooldown_seconds"] = int(h.svc.EmailOTPResendCooldown().Seconds()) attrs["email_otp_max_resend_per_hour"] = h.svc.EmailOTPMaxResendPerHour() } } return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{ Type: "auth_totp_required", Attributes: attrs, }) } if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) { tokens, err := h.svc.IssueTokensWithClientAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err != nil { return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.IssueTokens", err) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_tokens", Attributes: map[string]any{ "access_expires_in": int64(h.svc.AccessTTL().Seconds()), "refresh_expires_in": int64(tokens.RefreshTTL.Seconds()), "requires_totp_setup": true, "email_otp_enabled": false, }, }) } emailChallengeToken, err := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Email OTP challenge failed", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{ Type: "auth_email_otp_required", Attributes: map[string]any{ "requires_email_otp": true, "challenge_token": emailChallengeToken, "resend_cooldown_seconds": int(h.svc.EmailOTPResendCooldown().Seconds()), "max_resend_per_hour": h.svc.EmailOTPMaxResendPerHour(), "requires_totp_setup": true, "webauthn_configured": webauthnConfigured, "linked_sso": linkedSSO, "pin_configured": pinConfigured, }, }) } // Exchange godoc // @Summary Exchange frontend Microsoft SSO token into backend session // @Description Accepts Microsoft token payload from frontend ssoSilent and issues backend auth cookies. // @Tags Auth // @Accept json // @Produce json // @Param request body dto.ExchangeRequest true "JSON:API Exchange request" // @Success 200 {object} dto.TokenResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/exchange [post] func (h *AuthHandler) Exchange(c *fiber.Ctx) error { var req dto.ExchangeRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } identity, err := h.svc.ExchangeMicrosoftToken( c.UserContext(), req.Data.Attributes.AccessToken, req.Data.Attributes.IDToken, req.Data.Attributes.IDTokenClaims.ToMap(), ) if err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "an internal error occurred", }}) } user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID) if err != nil || user == nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "linked user is not found", }}) } // Issue internal session tokens. Keep auth provider as local since FE-managed ssoSilent // does not provide backend-managed Microsoft refresh token. tokens, err := h.svc.IssueTokensWithClientAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Token issue failed", Detail: "an internal error occurred", }}) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_tokens", Attributes: map[string]any{ "access_expires_in": int64(h.svc.AccessTTL().Seconds()), "refresh_expires_in": int64(tokens.RefreshTTL.Seconds()), }, }) } // VerifyEmail godoc // @Summary Verify email // @Description Confirms email verification after register. // @Tags Auth // @Produce json // @Param token query string true "Verification token" // @Success 200 {object} dto.AuthSessionDeleteResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/verify-email [get] func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error { token := c.Query("token") if token == "" { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid token", Detail: "token is required", Source: &jsonapi.ErrorSource{Pointer: "/query/token"}, }}) } if err := h.svc.VerifyEmail(c.UserContext(), token); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid token", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_verify_email", Attributes: map[string]any{ "verified": true, }, }) } // MicrosoftLogin godoc // @Summary Microsoft Entra SSO login // @Description Returns JSON:API payload containing Microsoft authorization URL. // @Description Backend starts with silent session-check (`prompt=none`). If Microsoft session is inactive, callback automatically falls back to interactive login. // @Tags Auth // @Produce json // @Success 200 {object} dto.MicrosoftLoginResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/login [get] func (h *AuthHandler) MicrosoftLogin(c *fiber.Ctx) error { // Interactive sign-in for an explicit "Login with Microsoft" action. Silent session // checks (prompt=none) are served separately by /microsoft/login-check. urlStr, err := h.svc.BeginMicrosoftLogin(c.UserContext()) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "SSO not configured", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, }, }) } // MicrosoftLoginCheck godoc // @Summary Microsoft Entra silent session check URL // @Description Returns JSON:API payload containing Microsoft authorization URL with prompt=none for strict session check. // @Tags Auth // @Produce json // @Success 200 {object} dto.MicrosoftLoginResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/login-check [get] func (h *AuthHandler) MicrosoftLoginCheck(c *fiber.Ctx) error { urlStr, err := h.svc.BeginMicrosoftSessionCheckStrict(c.UserContext()) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "SSO not configured", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, }, }) } // MicrosoftLink godoc // @Summary Microsoft Entra SSO link URL // @Description Returns JSON:API payload containing Microsoft authorization URL for linking SSO to the current authenticated user. // @Tags Auth // @Produce json // @Security BearerAuth // @Success 200 {object} dto.MicrosoftLoginResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/link [get] func (h *AuthHandler) MicrosoftLink(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } urlStr, err := h.svc.BeginMicrosoftLink(c.UserContext(), userID) if err != nil { status := fiber.StatusBadRequest title := "SSO link failed" detail := "an internal error occurred" if errors.Is(err, service.ErrUserNotFound) { status = fiber.StatusNotFound title = "User not found" detail = "user not found" } return writeAuthErrors(c, status, []jsonapi.ErrorObject{{ Status: strconv.Itoa(status), Title: title, Detail: detail, }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, }, }) } // MicrosoftReauth godoc // @Summary Microsoft Entra SSO reauth URL // @Description Starts a Microsoft reauthentication flow for supported sensitive actions. // @Tags Auth // @Produce json // @Param purpose query string true "Supported values: pin_reset_request, pin_reset_confirm" // @Param token query string false "Required when purpose=pin_reset_confirm" // @Success 200 {object} dto.MicrosoftLoginResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/reauth [get] func (h *AuthHandler) MicrosoftReauth(c *fiber.Ctx) error { purpose := strings.TrimSpace(c.Query("purpose")) var ( urlStr string err error ) switch purpose { case "pin_reset_request": userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } urlStr, err = h.svc.BeginMicrosoftReauth(c.UserContext(), userID, purpose) case "pin_reset_confirm": urlStr, err = h.svc.BeginMicrosoftPINResetReauth(c.UserContext(), c.Query("token")) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid reauth request", Detail: "purpose must be one of: pin_reset_request, pin_reset_confirm", }}) } if err != nil { status := fiber.StatusBadRequest title := "SSO reauth failed" detail := "an internal error occurred" if errors.Is(err, service.ErrInvalidResetLink) { title = "Invalid reset link" detail = "This reset link is invalid or expired." } return writeAuthErrors(c, status, []jsonapi.ErrorObject{{ Status: "400", Title: title, Detail: detail, }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, "purpose": purpose, }, }) } // MicrosoftCallback godoc // @Summary Microsoft Entra SSO callback // @Description Completes Microsoft SSO login. Login succeeds only when SSO identity is already linked to an existing user. // @Tags Auth // @Produce json // @Param code query string true "Authorization code" // @Param state query string true "State" // @Success 200 {object} dto.MicrosoftCallbackResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/callback [get] func (h *AuthHandler) MicrosoftCallback(c *fiber.Ctx) error { requestID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID)) if requestID == "" { requestID = strings.TrimSpace(c.Get(fiber.HeaderXRequestID)) } if requestID == "" { requestID = strings.TrimSpace(c.Get("X-Request-Id")) } if requestID == "" { requestID = strings.TrimSpace(c.Get("X-Request-ID")) } if requestID == "" { requestID = strings.TrimSpace(c.Get("x-request-id")) } if requestID == "" { requestID = strings.TrimSpace(c.Get("x-requestid")) } if requestID == "" { if v := c.Locals("requestid"); v != nil { requestID = strings.TrimSpace(fmt.Sprint(v)) } } logCallbackFailure := func(status int, reason, title, detail string, cause error) { args := []any{ slog.String("request_id", requestID), slog.Int("status", status), slog.String("reason", strings.TrimSpace(reason)), slog.String("title", strings.TrimSpace(title)), slog.String("detail", strings.TrimSpace(detail)), slog.String("path", c.Path()), slog.String("mode", strings.TrimSpace(c.Query("mode"))), slog.String("state", strings.TrimSpace(c.Query("state"))), slog.String("microsoft_error", strings.TrimSpace(c.Query("error"))), slog.Bool("has_code", strings.TrimSpace(c.Query("code")) != ""), } if cause != nil { args = append(args, slog.Any("cause", cause)) } slog.WarnContext(c.UserContext(), "microsoft callback failed", args...) } writeCallbackError := func(status int, title, detail, reason string, source *jsonapi.ErrorSource) error { logCallbackFailure(status, reason, title, detail, nil) if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) { if redirectURL, parseErr := url.Parse(redirect); parseErr == nil { q := redirectURL.Query() q.Set("sso", "error") q.Set("status", strconv.Itoa(status)) if strings.TrimSpace(reason) != "" { q.Set("reason", strings.TrimSpace(reason)) } if strings.TrimSpace(title) != "" { q.Set("title", strings.TrimSpace(title)) } redirectURL.RawQuery = q.Encode() return c.Redirect(redirectURL.String(), fiber.StatusFound) } } errObj := jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: detail, Source: source, } return writeAuthErrors(c, status, []jsonapi.ErrorObject{errObj}) } code := strings.TrimSpace(c.Query("code")) state := strings.TrimSpace(c.Query("state")) if state == "" { return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "state is required", "invalid_callback", nil) } mode, err := h.svc.MicrosoftStateMode(c.UserContext(), state) if err != nil { logCallbackFailure( fiber.StatusBadRequest, "invalid_callback_state", "Invalid callback state", err.Error(), err, ) return writeCallbackError( fiber.StatusBadRequest, "Invalid callback state", "an internal error occurred", "invalid_callback_state", &jsonapi.ErrorSource{Pointer: "/query/state"}, ) } if code == "" { ssoErr := strings.TrimSpace(c.Query("error")) if mode == "login_check" { h.svc.ClearMicrosoftState(c.UserContext(), state) if ssoErr != "" { urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext()) if loginErr != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Microsoft session not active", Detail: "microsoft sso session is not active: " + ssoErr, }}) } if shouldUseBrowserRedirect(c) { return c.Redirect(urlStr, fiber.StatusFound) } return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, "interaction_required": true, "microsoft_error": ssoErr, }, }) } } if mode == "login_check_strict" && isMicrosoftInteractionRequiredError(ssoErr) { h.svc.ClearMicrosoftState(c.UserContext(), state) urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext()) if loginErr != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Microsoft session not active", Detail: "microsoft sso session is not active: " + ssoErr, }}) } if shouldUseBrowserRedirect(c) { return c.Redirect(urlStr, fiber.StatusFound) } return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{ Type: "auth_microsoft_url", Attributes: map[string]any{ "url": urlStr, "redirect_url": urlStr, "interaction_required": true, "microsoft_error": ssoErr, }, }) } if mode == "login_check_strict" { h.svc.ClearMicrosoftState(c.UserContext(), state) return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Microsoft session not active", Detail: "microsoft sso session is not active", }}) } return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "code is required", "missing_code", nil) } if mode == "reauth" { reauthToken, purpose, err := h.svc.CompleteMicrosoftReauth(c.UserContext(), code, state) if err != nil { status := fiber.StatusBadRequest title := "SSO reauth failed" if errors.Is(err, service.ErrInvalidSSOState) { title = "Invalid callback state" } if errors.Is(err, service.ErrInvalidSSOReauth) { status = fiber.StatusUnauthorized } reason := "sso_reauth_failed" if status == fiber.StatusUnauthorized { reason = "invalid_sso_reauth" } logCallbackFailure(status, reason, title, err.Error(), err) return writeCallbackError(status, title, "an internal error occurred", reason, nil) } if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) { redirectURL, parseErr := url.Parse(redirect) if parseErr == nil { q := redirectURL.Query() q.Set("sso_reauth", "1") q.Set("purpose", purpose) q.Set("reauth_token", reauthToken) redirectURL.RawQuery = q.Encode() return c.Redirect(redirectURL.String(), fiber.StatusFound) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_reauth", Attributes: map[string]any{ "purpose": purpose, "reauth_token": reauthToken, }, }) } if mode == "link" { identity, err := h.svc.CompleteMicrosoftLink(c.UserContext(), code, state) if err != nil { logCallbackFailure(fiber.StatusBadRequest, "sso_link_failed", "SSO link failed", err.Error(), err) switch { case errors.Is(err, service.ErrInvalidSSOState): return writeCallbackError( fiber.StatusBadRequest, "Invalid callback state", "an internal error occurred", "invalid_callback_state", &jsonapi.ErrorSource{Pointer: "/query/state"}, ) case errors.Is(err, service.ErrUserNotFound): return writeCallbackError(fiber.StatusNotFound, "User not found", "user not found", "user_not_found", nil) case errors.Is(err, service.ErrSSOAlreadyLinked): return writeCallbackError( fiber.StatusConflict, "SSO already linked", "user already has an sso mapping", "sso_already_linked", nil, ) case errors.Is(err, service.ErrSSOLinkedToAnotherUser): return writeCallbackError( fiber.StatusConflict, "SSO linked to another user", "this sso account is already linked to another user", "sso_linked_to_another_user", nil, ) default: return writeCallbackError(fiber.StatusBadRequest, "SSO link failed", "an internal error occurred", "sso_link_failed", nil) } } if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) { redirectURL, parseErr := url.Parse(redirect) if parseErr == nil { q := redirectURL.Query() q.Set("sso_link", "1") q.Set("provider", identity.Provider) redirectURL.RawQuery = q.Encode() return c.Redirect(redirectURL.String(), fiber.StatusFound) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_sso_link", Attributes: map[string]any{ "provider": identity.Provider, "provider_subject": identity.ProviderSubject, "email": identity.Email, }, }) } if mode == "login_check_strict" { if _, _, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state); err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Microsoft session not active", Detail: "microsoft sso session is not active", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_session", Attributes: map[string]any{ "active": true, }, }) } identity, microsoftSessionID, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state) if err != nil { logCallbackFailure(fiber.StatusBadRequest, "sso_login_failed", "SSO login failed", err.Error(), err) switch { case errors.Is(err, service.ErrInvalidSSOState): return writeCallbackError( fiber.StatusBadRequest, "Invalid callback state", "an internal error occurred", "invalid_callback_state", &jsonapi.ErrorSource{Pointer: "/query/state"}, ) case errors.Is(err, service.ErrSSONotLinked): return writeCallbackError( fiber.StatusUnauthorized, "SSO not linked", "sso identity is not linked to any user", "sso_not_linked", nil, ) case errors.Is(err, service.ErrSSOEmailNotFound): return writeCallbackError( fiber.StatusUnauthorized, "SSO email not found", "sso account email is not registered", "sso_email_not_found", nil, ) case errors.Is(err, service.ErrUserNotFound): return writeCallbackError(fiber.StatusNotFound, "User not found", "linked user is not found", "user_not_found", nil) default: return writeCallbackError(fiber.StatusBadRequest, "SSO login failed", "an internal error occurred", "sso_login_failed", nil) } } // Issue cookies and redirect to FE if configured user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID) if err == nil && user != nil { tokens, err := h.svc.IssueTokensWithClientForProviderAndMicrosoftSID( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), "microsoft", microsoftSessionID, ) if err == nil { setAuthCookiesForProvider(c, h.svc, tokens, "microsoft") } } if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" { if shouldUseBrowserRedirect(c) { return c.Redirect(redirect, fiber.StatusFound) } } id, _ := uuidv7.BytesToString(identity.ID) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_identity", ID: id, Attributes: map[string]any{ "provider": identity.Provider, "provider_subject": identity.ProviderSubject, "email": identity.Email, }, }) } func isMicrosoftInteractionRequiredError(raw string) bool { code := strings.ToLower(strings.TrimSpace(raw)) if code == "" { return false } return strings.Contains(code, "aadsts16000") || code == "login_required" || code == "interaction_required" || code == "account_selection_required" || code == "consent_required" } // SSOLink godoc // @Summary Link Microsoft SSO to current user // @Description Link Microsoft SSO identity to the authenticated user. SSO login will only work after successful link. // @Tags Auth // @Accept json // @Produce json // @Security BearerAuth // @Param request body dto.SSOLinkRequest true "JSON:API SSO link request" // @Success 200 {object} dto.SSOLinkResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Router /api/v1/auth/sso/link [post] func (h *AuthHandler) SSOLink(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.SSOLinkRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } identity, err := h.svc.LinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider, req.Data.Attributes.Code) if err != nil { switch { case errors.Is(err, service.ErrUserNotFound): return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "User not found", Detail: "user not found", }}) case errors.Is(err, service.ErrSSOAlreadyLinked): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "SSO already linked", Detail: "user already has an sso mapping", }}) case errors.Is(err, service.ErrSSOLinkedToAnotherUser): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "SSO linked to another user", Detail: "this sso account is already linked to another user", }}) case errors.Is(err, service.ErrUnsupportedSSOProvider): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Unsupported SSO provider", Detail: "unsupported sso provider", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "SSO link failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_sso_link", Attributes: map[string]any{ "provider": identity.Provider, "provider_subject": identity.ProviderSubject, "email": identity.Email, }, }) } // SSOUnlink godoc // @Summary Unlink Microsoft SSO from current user // @Description Remove Microsoft SSO mapping from authenticated user. // @Tags Auth // @Accept json // @Produce json // @Security BearerAuth // @Param request body dto.SSOUnlinkRequest true "JSON:API SSO unlink request" // @Success 200 {object} dto.SSOUnlinkResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/auth/sso/unlink [delete] func (h *AuthHandler) SSOUnlink(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.SSOUnlinkRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.UnlinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider); err != nil { switch { case errors.Is(err, service.ErrUserNotFound): return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "User not found", Detail: "user not found", }}) case errors.Is(err, service.ErrSSONotLinked): return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "SSO not linked", Detail: "sso is not linked to this user", }}) case errors.Is(err, service.ErrUnsupportedSSOProvider): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Unsupported SSO provider", Detail: "unsupported sso provider", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "SSO unlink failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_sso_unlink", Attributes: map[string]any{ "provider": req.Data.Attributes.Provider, "unlinked": true, }, }) } func shouldUseBrowserRedirect(c *fiber.Ctx) bool { if strings.EqualFold(strings.TrimSpace(c.Get("X-Requested-With")), "XMLHttpRequest") { return false } mode := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Mode"))) dest := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Dest"))) if dest == "empty" || mode == "cors" || mode == "same-origin" { return false } accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept))) return !strings.Contains(accept, "application/json") } // WebAuthnRegisterBegin godoc // @Summary Begin WebAuthn registration // @Description Creates WebAuthn registration options for the authenticated user. // @Tags Auth - WebAuthn // @Accept json // @Produce json // @Param request body dto.WebAuthnRegisterBeginRequest true "JSON:API WebAuthn register begin request" // @Success 200 {object} dto.WebAuthnBeginResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/webauthn/register/begin [post] func (h *AuthHandler) WebAuthnRegisterBegin(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.WebAuthnRegisterBeginRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } options, challengeToken, err := h.svc.BeginWebAuthnRegistration(c.UserContext(), userID) if err != nil { switch { case errors.Is(err, service.ErrWebAuthnNotConfigured): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn not configured", Detail: "webauthn is not configured", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn begin failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_webauthn_register_options", Attributes: map[string]any{ "challenge_token": challengeToken, "challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(), "public_key": options.Response, }, }) } // WebAuthnRegisterFinish godoc // @Summary Finish WebAuthn registration // @Description Verifies WebAuthn registration response and stores the credential. // @Tags Auth - WebAuthn // @Accept json // @Produce json // @Param request body dto.WebAuthnRegisterFinishRequest true "JSON:API WebAuthn register finish request" // @Success 200 {object} dto.AuthSessionsResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/webauthn/register/finish [post] func (h *AuthHandler) WebAuthnRegisterFinish(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.WebAuthnRegisterFinishRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.FinishWebAuthnRegistration(c.UserContext(), userID, req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential, req.Data.Attributes.Name); err != nil { switch { case errors.Is(err, service.ErrWebAuthnNotConfigured): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn not configured", Detail: "webauthn is not configured", }}) case errors.Is(err, service.ErrWebAuthnChallengeInvalid): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid challenge", Detail: "challenge token is invalid or expired", }}) case errors.Is(err, service.ErrWebAuthnPayloadInvalid): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "credential payload is invalid", Source: &jsonapi.ErrorSource{ Pointer: "/data/attributes/credential", }, }}) case errors.Is(err, service.ErrWebAuthnCredentialExists): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "Credential already exists", Detail: "credential has already been registered", }}) case errors.Is(err, service.ErrWebAuthnVerificationFailed): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Verification failed", Detail: "unable to verify webauthn credential", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn registration failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_webauthn_register", Attributes: map[string]any{ "registered": true, }, }) } // WebAuthnLoginBegin godoc // @Summary Begin WebAuthn login // @Description Creates WebAuthn assertion options for a user email. // @Tags Auth - WebAuthn // @Accept json // @Produce json // @Param request body dto.WebAuthnLoginBeginRequest true "JSON:API WebAuthn login begin request" // @Success 200 {object} dto.WebAuthnBeginResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/webauthn/login/begin [post] func (h *AuthHandler) WebAuthnLoginBegin(c *fiber.Ctx) error { var req dto.WebAuthnLoginBeginRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } options, challengeToken, err := h.svc.BeginWebAuthnLogin(c.UserContext(), req.Data.Attributes.Email) if err != nil { switch { case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrEmailNotVerified): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid credentials", }}) case errors.Is(err, service.ErrWebAuthnCredentialUnavailable): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "No WebAuthn credential", Detail: "webauthn credential is not configured for this account", }}) case errors.Is(err, service.ErrWebAuthnNotConfigured): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn not configured", Detail: "webauthn is not configured", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn begin failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_webauthn_login_options", Attributes: map[string]any{ "challenge_token": challengeToken, "challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(), "public_key": options.Response, }, }) } // WebAuthnLoginFinish godoc // @Summary Finish WebAuthn login // @Description Verifies WebAuthn assertion and issues auth cookies. // @Tags Auth - WebAuthn // @Accept json // @Produce json // @Param request body dto.WebAuthnLoginFinishRequest true "JSON:API WebAuthn login finish request" // @Success 200 {object} dto.TokenResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/webauthn/login/finish [post] func (h *AuthHandler) WebAuthnLoginFinish(c *fiber.Ctx) error { var req dto.WebAuthnLoginFinishRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } user, err := h.svc.FinishWebAuthnLogin(c.UserContext(), req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential) if err != nil { switch { case errors.Is(err, service.ErrWebAuthnChallengeInvalid): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid challenge", Detail: "challenge token is invalid or expired", }}) case errors.Is(err, service.ErrWebAuthnPayloadInvalid): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "credential payload is invalid", Source: &jsonapi.ErrorSource{ Pointer: "/data/attributes/credential", }, }}) case errors.Is(err, service.ErrWebAuthnVerificationFailed), errors.Is(err, service.ErrInvalidCredentials): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid webauthn assertion", }}) case errors.Is(err, service.ErrWebAuthnCredentialUnavailable): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "No WebAuthn credential", Detail: "webauthn credential is not configured for this account", }}) case errors.Is(err, service.ErrWebAuthnNotConfigured): return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn not configured", Detail: "webauthn is not configured", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn login failed", Detail: "an internal error occurred", }}) } } tokens, err := h.svc.IssueTokensWithClientAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Token issue failed", Detail: "an internal error occurred", }}) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_tokens", Attributes: map[string]any{ "access_expires_in": int64(h.svc.AccessTTL().Seconds()), "refresh_expires_in": int64(tokens.RefreshTTL.Seconds()), }, }) } // WebAuthnResetSetup godoc // @Summary Reset WebAuthn setup // @Description Removes all registered WebAuthn credentials so the user can re-setup passkeys. // @Description For method=pin, obtain token from /auth/pin/verify with action=1f7d457e-0a19-4a36-8d9f-2c66fd2369ab and send it in X-PIN-Verification-Token header. // @Tags Auth - WebAuthn // @Accept json // @Produce json // @Param request body dto.WebAuthnResetSetupRequest true "JSON:API WebAuthn reset setup request" // @Success 200 {object} dto.AuthSessionDeleteResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/webauthn/reset-setup [post] func (h *AuthHandler) WebAuthnResetSetup(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.WebAuthnResetSetupRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method)) switch method { case "pin": sessionID := "" if accessToken := readValidAccessToken(c, h.svc); accessToken != "" { sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid or expired token", }}) } sessionID = sid } token := c.Get(pinVerificationHeader) if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionWebAuthnResetSetup, token, sessionID); err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "PIN verification required", Detail: "valid PIN action token is required", Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"}, }}) } if err := h.svc.DeleteWebAuthnSetup(c.UserContext(), userID); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn reset setup failed", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_webauthn_reset_setup", Attributes: map[string]any{ "reset": true, }, }) case "password": if req.Data.Attributes.Password == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "password is required when method is password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"}, }}) } default: return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: pin, password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) } if err := h.svc.ResetWebAuthnSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil { switch { case errors.Is(err, service.ErrInvalidReauthMethod): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: pin, password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid authentication factor", }}) case errors.Is(err, service.ErrSecurityPINNotSet): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "PIN not configured", Detail: "security PIN is not set", }}) case errors.Is(err, service.ErrSecurityPINAttemptLimit): return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{ Status: "423", Title: "PIN blocked", Detail: "security PIN is blocked. reset PIN is required.", }}) case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired): return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{ Status: "423", Title: "PIN blocked", Detail: "security PIN is blocked. reset PIN is required.", }}) case errors.Is(err, service.ErrInvalidToken): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "user not found", }}) default: return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "WebAuthn reset setup failed", Detail: "an internal error occurred", }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_webauthn_reset_setup", Attributes: map[string]any{ "reset": true, }, }) } // Me godoc // @Summary Get current user // @Tags Auth // @Produce json // @Success 200 {object} dto.AuthCurrentUserResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/me [get] func (h *AuthHandler) Me(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } user, err := h.svc.GetUserByID(c.UserContext(), userID) if err != nil || user == nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "user not found", }}) } id, _ := uuidv7.BytesToString(user.ID) var emailVerifiedAt string if user.EmailVerifiedAt != nil { emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00") } totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID) isPreviouslyConfigured, _ := h.svc.IsTOTPPreviouslyConfigured(c.UserContext(), user.ID) webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID) linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID) microsoftScope, _ := h.svc.GetMicrosoftScopeByUserID(c.UserContext(), user.ID) pinConfigured := len(user.SecurityPINHash) > 0 pinBlocked := h.svc.IsSecurityPINBlocked(c.UserContext(), user.ID) loginMethod := "unknown" authProvider := "unknown" if accessToken := readValidAccessToken(c, h.svc); accessToken != "" { if method, provider, err := h.svc.GetSessionAuthContextFromAccessToken(c.UserContext(), accessToken); err == nil { loginMethod = method authProvider = provider } } _, roles := authRoleResponseData(h.svc, c, user) permissions := permissionAttrs(h.svc, c, user.ID) permissionModules := permissionModuleGroups(h.svc, c, user.ID) foto := authUserFoto(c.UserContext(), h.storage, user) dulBases := make([]map[string]any, 0) if h.contactSvc != nil { contactRow, contactErr := h.contactSvc.GetContactByID(c.UserContext(), user.ID) if contactErr == nil && isPilotRoleContact(contactRow) { if parsed := contactDULBases(contactRow.BaseRolesRaw); len(parsed) > 0 { dulBases = parsed } } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "user", ID: id, Attributes: map[string]any{ "email": user.Email, "email_sso": derefString(user.SSOEmail), "first_name": user.FirstName, "last_name": user.LastName, "mobile_phone": user.MobilePhone, "timezone": tzpkg.WithDefault(user.Timezone), "foto": foto, "email_verified_at": emailVerifiedAt, "roles": roles, "permissions": permissions, "permission_modules": permissionModules, "totp_enabled": totpEnabled, "is_previously_configured": isPreviouslyConfigured, "webauthn_configured": webauthnConfigured, "linked_sso": linkedSSO, "pin_configured": pinConfigured, "pin_blocked": pinBlocked, "login_method": loginMethod, "auth_provider": authProvider, "microsoft_scope": microsoftScope, "dul_bases": dulBases, }, }) } // UpdateTimezone godoc // @Summary Update current user timezone // @Tags Auth // @Accept json // @Produce json // @Param request body dto.TimezoneUpdateRequest true "JSON:API timezone update request" // @Success 200 {object} dto.AuthCurrentUserResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/me/timezone [patch] func (h *AuthHandler) UpdateTimezone(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.TimezoneUpdateRequest if err := c.BodyParser(&req); err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } normalizedTimezone, err := tzpkg.Normalize(req.Data.Attributes.Timezone) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err)) } if _, err := h.svc.UpdateUserTimezone(c.UserContext(), userID, normalizedTimezone); err != nil { if errors.Is(err, tzpkg.ErrEmpty) || errors.Is(err, tzpkg.ErrInvalid) { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err)) } return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: "an internal error occurred", }}) } user, err := h.svc.GetUserByID(c.UserContext(), userID) if err != nil || user == nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "user not found", }}) } id, _ := uuidv7.BytesToString(user.ID) var emailVerifiedAt string if user.EmailVerifiedAt != nil { emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00") } _, roles := authRoleResponseData(h.svc, c, user) permissions := permissionAttrs(h.svc, c, user.ID) permissionModules := permissionModuleGroups(h.svc, c, user.ID) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "user", ID: id, Attributes: map[string]any{ "email": user.Email, "first_name": user.FirstName, "last_name": user.LastName, "mobile_phone": user.MobilePhone, "timezone": tzpkg.WithDefault(user.Timezone), "email_verified_at": emailVerifiedAt, "roles": roles, "permissions": permissions, "permission_modules": permissionModules, }, }) } // Refresh godoc // @Summary Refresh access token // @Tags Auth - Refresh // @Produce json // @Success 200 {object} dto.TokenResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/refresh [post] func (h *AuthHandler) Refresh(c *fiber.Ctx) error { var ( tokens service.TokenPair err error ) refreshCandidates := service.CookieValues(c.Get(fiber.HeaderCookie), h.svc.RefreshCookieName()) if len(refreshCandidates) == 0 { refreshCandidates = []string{strings.TrimSpace(c.Cookies(h.svc.RefreshCookieName()))} } for _, refreshToken := range refreshCandidates { tokens, err = h.svc.RefreshTokensWithClientAndDeviceType( c.UserContext(), refreshToken, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err == nil { break } } if err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "an internal error occurred", }}) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_tokens", Attributes: map[string]any{ "access_expires_in": int64(h.svc.AccessTTL().Seconds()), "refresh_expires_in": int64(tokens.RefreshTTL.Seconds()), "microsoft": map[string]any{ "status": "checked_during_refresh", "token": map[string]any{}, }, }, }) } // Logout godoc // @Summary Logout (revoke refresh) // @Tags Auth - Refresh // @Produce json // @Success 200 {object} dto.VerifyEmailResponse // @Router /api/v1/auth/logout [post] func (h *AuthHandler) Logout(c *fiber.Ctx) error { accessToken := readValidAccessToken(c, h.svc) refreshToken := readValidRefreshToken(c, h.svc) if accessToken != "" { if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 { _ = h.svc.RevokeAllUserSessions(c.UserContext(), userID) } } _ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken) clearAuthCookies(c, h.svc) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_logout", Attributes: map[string]any{ "logged_out": true, }, }) } // MicrosoftRefresh godoc // @Summary Refresh Microsoft access token // @Tags Auth - SSO // @Produce json // @Success 200 {object} dto.TokenResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 428 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/refresh [post] func (h *AuthHandler) MicrosoftRefresh(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } tokenSet, err := h.svc.RefreshMicrosoftAccessToken(c.UserContext(), userID) if err != nil { if errors.Is(err, service.ErrMicrosoftReauthRequired) { return writeAuthErrors(c, fiber.StatusPreconditionRequired, []jsonapi.ErrorObject{{ Status: "428", Title: "Reauthentication required", Detail: "microsoft session requires reauthentication", }}) } return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_tokens", Attributes: map[string]any{ "access_token": tokenSet.AccessToken, "token_type": tokenSet.TokenType, "scope": tokenSet.Scope, "expires_in": tokenSet.ExpiresIn, }, }) } // MicrosoftLogout godoc // @Summary Logout Microsoft OAuth session // @Tags Auth - SSO // @Produce json // @Success 200 {object} dto.VerifyEmailResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/microsoft/logout [post] func (h *AuthHandler) MicrosoftLogout(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } if err := h.svc.LogoutMicrosoft(c.UserContext(), userID); err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "an internal error occurred", }}) } _ = h.svc.RevokeRefreshToken(c.UserContext(), readValidRefreshToken(c, h.svc)) clearAuthCookies(c, h.svc) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_microsoft_logout", Attributes: map[string]any{ "logged_out": true, }, }) } // MicrosoftFrontChannelLogout godoc // @Summary Microsoft front-channel logout callback // @Description OIDC front-channel logout endpoint. Configure this URL as the "Front-channel logout URL" in Microsoft Entra. Microsoft loads it in a hidden iframe when the user signs out at the IdP (or from another RP). It clears local auth cookies and revokes the server-side session plus the stored Microsoft refresh token for the browser that owns the request. It never redirects and always returns 200 so the IdP logout flow can complete. // @Tags Auth - SSO // @Produce html // @Success 200 {string} string "logged out" // @Router /api/v1/auth/microsoft/front-channel-logout [get] func (h *AuthHandler) MicrosoftFrontChannelLogout(c *fiber.Ctx) error { // This endpoint is loaded by the IdP inside an iframe. Responses must not be // cached and must not redirect (a redirect would break the IdP logout flow). c.Set(fiber.HeaderCacheControl, "no-store, max-age=0") c.Set(fiber.HeaderPragma, "no-cache") c.Set(fiber.HeaderXFrameOptions, "") c.Set("Content-Security-Policy", "frame-ancestors https://login.microsoftonline.com https://*.microsoftonline.com https://login.live.com") // OIDC front-channel logout MAY include iss (issuer) and sid (session id). When an // issuer is supplied it must look like a Microsoft issuer; otherwise ignore the // request body but still answer 200 as the spec requires. if iss := strings.TrimSpace(c.Query("iss")); iss != "" && !isMicrosoftIssuer(iss) { return sendFrontChannelLogoutOK(c) } revoked := false if sid := strings.TrimSpace(c.Query("sid")); sid != "" { if err := h.svc.RevokeMicrosoftSessionBySID(c.UserContext(), sid); err == nil { revoked = true } } // Cookie-based revocation remains as a fallback for browsers that still // send the app cookies inside the front-channel iframe. if accessToken := readValidAccessToken(c, h.svc); accessToken != "" { if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 { _ = h.svc.RevokeAllUserSessions(c.UserContext(), userID) _ = h.svc.LogoutMicrosoft(c.UserContext(), userID) revoked = true } } if refreshToken := readValidRefreshToken(c, h.svc); refreshToken != "" { _ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken) revoked = true } clearAuthCookiesForFrontChannel(c, h.svc) _ = revoked return sendFrontChannelLogoutOK(c) } func sendFrontChannelLogoutOK(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, "text/html; charset=utf-8") return c.Status(fiber.StatusOK).SendString(`Logged out`) } // isMicrosoftIssuer reports whether iss is a plausible Microsoft Entra issuer URL. func isMicrosoftIssuer(iss string) bool { iss = strings.TrimSpace(iss) if iss == "" { return false } u, err := url.Parse(iss) if err != nil || u.Scheme != "https" { return false } host := strings.ToLower(u.Hostname()) return host == "login.microsoftonline.com" || host == "sts.windows.net" || strings.HasSuffix(host, ".microsoftonline.com") || strings.HasSuffix(host, ".windows.net") } // Sessions godoc // @Summary List active sessions // @Description Lists all active sessions/devices for current user. // @Tags Auth - Refresh // @Produce json // @Success 200 {object} dto.AuthSessionsResponse // @Failure 401 {object} dto.ErrorResponse // @Router /api/v1/auth/sessions [get] func (h *AuthHandler) Sessions(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } currentSessionID := "" if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" { sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err == nil { currentSessionID = sid } } sessions, err := h.svc.ListUserSessions(c.UserContext(), userID, currentSessionID) if err != nil { return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List sessions failed", Detail: "an internal error occurred", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_sessions", Attributes: map[string]any{ "sessions": sessions, }, }) } // DeleteSession godoc // @Summary Delete one session // @Description Revokes one session/device immediately. Requires PIN action token (header X-PIN-Verification-Token) with action=3d7d1d5f-ec48-4eca-a7ea-37f4f7927d06. // @Tags Auth - Refresh // @Produce json // @Param session_id path string true "Session ID" // @Success 200 {object} dto.AuthSessionDeleteResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/sessions/{session_id} [delete] func (h *AuthHandler) DeleteSession(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } sessionID := strings.TrimSpace(c.Params("session_id")) if _, err := uuidv7.ParseString(sessionID); err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "session_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/session_id"}, }}) } if err := h.svc.RevokeUserSession(c.UserContext(), userID, sessionID); err != nil { switch { case errors.Is(err, service.ErrSessionNotFound): return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.SessionNotFound", apperrorsx.ErrDataNotFound, err) case errors.Is(err, service.ErrInvalidToken): return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.InvalidToken", apperrorsx.ErrUserUnauthorized, err) default: return writeHandlerErrorDefWithStatus(c, "AuthHandler.DeleteSession", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } } if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" { sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err == nil && sid == sessionID { clearAuthCookies(c, h.svc) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_session_delete", Attributes: map[string]any{ "deleted": true, }, }) } // TOTPSetup godoc // @Summary Setup TOTP // @Description Generates secret and otpauth URL. TOTP is NOT enabled until confirmed. // @Tags Auth - TOTP // @Accept json // @Produce json // @Param request body dto.TOTPSetupRequest true "JSON:API TOTP setup request" // @Success 200 {object} dto.TOTPSetupResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/totp/setup [post] func (h *AuthHandler) TOTPSetup(c *fiber.Ctx) error { authUserID := actorUserID(c) if len(authUserID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.TOTPSetupRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.TOTPSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } userID, err := uuidv7.ParseString(req.Data.Attributes.UserID) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"}, }}) } if !bytes.Equal(userID, authUserID) { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Forbidden", Detail: "user_id does not match authenticated user", }}) } user, err := h.svc.GetUserByID(c.UserContext(), authUserID) if err != nil || user == nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "authenticated user not found", }}) } secret, otpURL, issuer, err := h.svc.SetupTOTP(c.UserContext(), authUserID, user.Email) if err != nil { return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_totp_setup", Attributes: map[string]any{ "secret": secret, "otpauth_url": otpURL, "issuer": issuer, }, }) } // TOTPConfirm godoc // @Summary Confirm TOTP setup // @Description Validates initial TOTP code and enables TOTP for future logins. // @Tags Auth - TOTP // @Accept json // @Produce json // @Param request body dto.TOTPConfirmRequest true "JSON:API TOTP confirm request" // @Success 200 {object} dto.VerifyEmailResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/totp/confirm [post] func (h *AuthHandler) TOTPConfirm(c *fiber.Ctx) error { authUserID := actorUserID(c) if len(authUserID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.TOTPConfirmRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.TOTPConfirm.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } userID, err := uuidv7.ParseString(req.Data.Attributes.UserID) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"}, }}) } if !bytes.Equal(userID, authUserID) { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Forbidden", Detail: "user_id does not match authenticated user", }}) } ok, err := h.svc.ConfirmTOTP(c.UserContext(), authUserID, strings.TrimSpace(req.Data.Attributes.Code)) if err != nil || !ok { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid TOTP", Detail: "code is invalid or expired", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_totp_confirm", Attributes: map[string]any{ "confirmed": true, }, }) } // TOTPVerify godoc // @Summary Verify TOTP code // @Description Completes login when TOTP is enabled and returns auth cookies. // @Tags Auth - TOTP // @Accept json // @Produce json // @Param request body dto.TOTPVerifyRequest true "JSON:API TOTP verify request" // @Success 200 {object} dto.TokenResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/totp/verify [post] func (h *AuthHandler) TOTPVerify(c *fiber.Ctx) error { var req dto.TOTPVerifyRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.TOTPVerify.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } challengeToken := strings.TrimSpace(req.Data.Attributes.ChallengeToken) userID, err := h.svc.ResolveTOTPChallenge(c.UserContext(), challengeToken) if err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid challenge", Detail: "challenge token is invalid or expired", }}) } ok, err := h.svc.VerifyTOTP(c.UserContext(), userID, strings.TrimSpace(req.Data.Attributes.Code)) if err != nil || !ok { if _, attemptErr := h.svc.RegisterFailedTOTPChallengeAttempt(c.UserContext(), challengeToken); attemptErr != nil { if errors.Is(attemptErr, service.ErrTOTPChallengeAttemptLimit) { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid challenge", Detail: "challenge token is invalid or expired", }}) } } return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Invalid TOTP", Detail: "code is invalid or expired", }}) } h.svc.InvalidateTOTPChallenge(c.UserContext(), challengeToken) user, err := h.svc.GetUserByID(c.UserContext(), userID) if err != nil || user == nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "user not found", }}) } tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err != nil { return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPVerify.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_tokens", Attributes: map[string]any{ "access_expires_in": int64(h.svc.AccessTTL().Seconds()), "refresh_expires_in": int64(tokens.RefreshTTL.Seconds()), }, }) } // SendEmailOTP godoc // @Summary Send login OTP to email // @Description Sends a one-time 6-digit OTP to user email using login challenge token from /auth/login. // @Description Security controls: old OTP is invalidated, resend cooldown is enforced, and resend/hour quota is enforced. // @Tags Auth - Email OTP // @Accept json // @Produce json // @Param request body dto.EmailOTPSendRequest true "JSON:API Email OTP send request" // @Success 200 {object} dto.EmailOTPSendResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Router /api/v1/auth/email-otp/send [post] func (h *AuthHandler) SendEmailOTP(c *fiber.Ctx) error { if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Email OTP disabled", Detail: "email otp login is disabled", }}) } var req dto.EmailOTPSendRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.SendEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.SendLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken)); err != nil { if errors.Is(err, service.ErrEmailOTPRateLimited) || errors.Is(err, service.ErrEmailOTPResendExceeded) { return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP.RateLimited", fiber.StatusTooManyRequests, apperrorsx.ErrBusinessRuleFailed, err) } return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_email_otp_send", Attributes: map[string]any{ "sent": true, }, }) } // VerifyEmailOTP godoc // @Summary Verify login OTP from email // @Description Verifies one-time OTP and completes login by issuing auth cookies. // @Description OTP is one-time use, expires automatically, and failed attempts are limited and audited. // @Tags Auth - Email OTP // @Accept json // @Produce json // @Param request body dto.EmailOTPVerifyRequest true "JSON:API Email OTP verify request" // @Success 200 {object} dto.EmailOTPVerifyResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/email-otp/verify [post] func (h *AuthHandler) VerifyEmailOTP(c *fiber.Ctx) error { if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Email OTP disabled", Detail: "email otp login is disabled", }}) } var req dto.EmailOTPVerifyRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.VerifyEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } user, err := h.svc.VerifyLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken), strings.TrimSpace(req.Data.Attributes.Code)) if err != nil { if errors.Is(err, service.ErrInvalidEmailOTP) { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid otp code", }}) } return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType( c.UserContext(), user, c.Get(fiber.HeaderUserAgent), c.IP(), readDeviceTypeHint(c), ) if err != nil { return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } setAuthCookies(c, h.svc, tokens) return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_email_otp_verify", Attributes: map[string]any{ "verified": true, "requires_totp_setup": true, }, }) } // TOTPDisable godoc // @Summary Disable TOTP // @Tags Auth - TOTP // @Accept json // @Produce json // @Param request body dto.TOTPDisableRequest true "JSON:API TOTP disable request" // @Success 200 {object} dto.VerifyEmailResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/auth/totp/disable [post] func (h *AuthHandler) TOTPDisable(c *fiber.Ctx) error { authUserID := actorUserID(c) if len(authUserID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.TOTPDisableRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.TOTPDisable.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } userID, err := uuidv7.ParseString(req.Data.Attributes.UserID) if err != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"}, }}) } if !bytes.Equal(userID, authUserID) { return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{ Status: "403", Title: "Forbidden", Detail: "user_id does not match authenticated user", }}) } if err := h.svc.DisableTOTP(c.UserContext(), authUserID); err != nil { return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPDisable", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_totp_disable", Attributes: map[string]any{ "disabled": true, }, }) } // TOTPResetSetup godoc // @Summary Reset TOTP setup // @Description Removes TOTP setup so the user can re-setup authenticator. // @Description For method=pin, obtain token from /auth/pin/verify with action=0b5409b5-94f3-4aab-9de2-44b0ad5858f1 and send it in X-PIN-Verification-Token header. // @Tags Auth - TOTP // @Accept json // @Produce json // @Param request body dto.TOTPResetSetupRequest true "JSON:API TOTP reset setup request" // @Success 200 {object} dto.VerifyEmailResponse // @Failure 401 {object} dto.ErrorResponse // @Failure 409 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 429 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/auth/totp/reset-setup [post] func (h *AuthHandler) TOTPResetSetup(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").([]byte) if !ok || len(userID) == 0 { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } var req dto.TOTPResetSetupRequest if err := c.BodyParser(&req); err != nil { return writeHandlerErrorDef(c, "AuthHandler.TOTPResetSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs) } method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method)) switch method { case "pin": sessionID := "" if accessToken := readValidAccessToken(c, h.svc); accessToken != "" { sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid or expired token", }}) } sessionID = sid } token := c.Get(pinVerificationHeader) if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionTOTPResetSetup, token, sessionID); err != nil { return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "PIN verification required", Detail: "valid PIN action token is required", Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"}, }}) } if err := h.svc.DeleteTOTPSetup(c.UserContext(), userID); err != nil { return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup.Delete", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_totp_reset_setup", Attributes: map[string]any{ "reset": true, }, }) case "password": if req.Data.Attributes.Password == "" { return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "password is required when method is password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"}, }}) } default: return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: pin, password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) } if err := h.svc.ResetTOTPSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil { switch { case errors.Is(err, service.ErrInvalidReauthMethod): return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "method must be one of: pin, password", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"}, }}) case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid authentication factor", }}) case errors.Is(err, service.ErrSecurityPINNotSet): return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "PIN not configured", Detail: "security PIN is not set", }}) case errors.Is(err, service.ErrSecurityPINAttemptLimit): return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{ Status: "423", Title: "PIN blocked", Detail: "security PIN is blocked. reset PIN is required.", }}) case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired): return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{ Status: "423", Title: "PIN blocked", Detail: "security PIN is blocked. reset PIN is required.", }}) case errors.Is(err, service.ErrInvalidToken): return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "user not found", }}) default: return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "auth_totp_reset_setup", Attributes: map[string]any{ "reset": true, }, }) } func permissionAttrs(svc *service.AuthService, c *fiber.Ctx, userID []byte) []map[string]any { perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID) if err != nil { return []map[string]any{} } out := make([]map[string]any, 0, len(perms)) for i := range perms { pid, _ := uuidv7.BytesToString(perms[i].ID) out = append(out, map[string]any{ "id": pid, "name": perms[i].Name, "key": perms[i].Key, "description": perms[i].Description, "requires_pin": perms[i].RequiresPIN, }) } return out } // permissionModuleGroups returns the user's permissions grouped by module, in the // same shape as GET /roles/permissions/get-all. // TODO: remove permissionAttrs (the flat "permissions" field) once the frontend // migrates to "permission_modules". func permissionModuleGroups(svc *service.AuthService, c *fiber.Ctx, userID []byte) []dto.PermissionGroupResource { perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID) if err != nil { return []dto.PermissionGroupResource{} } return groupPermissionsByModule(perms) } func authRoleResponseData(svc *service.AuthService, c *fiber.Ctx, user *auth.User) (any, []map[string]any) { roleIDs := make([]string, 0, len(user.RoleIDs)+1) roles := make([]map[string]any, 0, len(user.Roles)+1) seen := make(map[string]struct{}, len(user.RoleIDs)+1) appendRole := func(roleID []byte, name, description string) { if len(roleID) != 16 { return } roleIDStr, _ := uuidv7.BytesToString(roleID) if _, exists := seen[roleIDStr]; exists { return } seen[roleIDStr] = struct{}{} roleIDs = append(roleIDs, roleIDStr) roles = append(roles, map[string]any{ "id": roleIDStr, "name": name, "description": description, }) } primaryRole := findAuthRoleByID(user, user.RoleID) if primaryRole == nil && len(user.RoleID) == 16 { primaryRole, _ = svc.GetRoleByID(c.UserContext(), user.RoleID) } if primaryRole != nil { appendRole(primaryRole.ID, primaryRole.Name, primaryRole.Description) } for i := range user.Roles { appendRole(user.Roles[i].ID, user.Roles[i].Name, user.Roles[i].Description) } for i := range user.RoleIDs { appendRole(user.RoleIDs[i], "", "") } if len(roles) == 0 && len(user.RoleID) == 16 { appendRole(user.RoleID, "", "") } // primaryRoleID := "" var primaryRoleAttrs any if len(roles) > 0 { // primaryRoleID = roleIDs[0] primaryRoleAttrs = roles[0] } return primaryRoleAttrs, roles } func roleResponseIDFields(roles []map[string]any) (any, []string) { roleIDs := make([]string, 0, len(roles)) for i := range roles { if roleID, ok := roles[i]["id"].(string); ok && roleID != "" { roleIDs = append(roleIDs, roleID) } } if len(roleIDs) == 0 { return nil, roleIDs } return roleIDs[0], roleIDs } func findAuthRoleByID(user *auth.User, roleID []byte) *auth.Role { if user == nil || len(roleID) != 16 { return nil } target := string(roleID) for i := range user.Roles { if string(user.Roles[i].ID) == target { role := user.Roles[i] return &role } } return nil } func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) { setAuthCookiesWithSameSite(c, svc, tokens, parseSameSite(svc.CookieSameSite()), svc.CookieSecure()) } func setAuthCookiesForProvider(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, authProvider string) { sameSite := parseSameSite(svc.CookieSameSite()) secure := svc.CookieSecure() if strings.EqualFold(strings.TrimSpace(authProvider), "microsoft") && secure { sameSite = "None" } setAuthCookiesWithSameSite(c, svc, tokens, sameSite, secure) } func setAuthCookiesWithSameSite(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, sameSite string, secure bool) { refreshTTL := tokens.RefreshTTL if refreshTTL <= 0 { refreshTTL = svc.RefreshTTL() } clearAuthCookieScopes(c, svc, sameSite, secure) c.Cookie(&fiber.Cookie{ Name: svc.AccessCookieName(), Value: tokens.AccessToken, HTTPOnly: true, Secure: secure, SameSite: sameSite, Domain: svc.CookieDomain(), Path: "/", MaxAge: int(svc.AccessTTL().Seconds()), }) c.Cookie(&fiber.Cookie{ Name: svc.RefreshCookieName(), Value: tokens.RefreshToken, HTTPOnly: true, Secure: secure, SameSite: sameSite, Domain: svc.CookieDomain(), Path: "/api/v1/auth/refresh", MaxAge: int(refreshTTL.Seconds()), }) } func clearAuthCookieScopes(c *fiber.Ctx, svc *service.AuthService, sameSite string, secure bool) { name := svc.AccessCookieName() refreshName := svc.RefreshCookieName() domain := strings.TrimSpace(svc.CookieDomain()) c.Cookie(&fiber.Cookie{ Name: name, Value: "", HTTPOnly: true, Secure: secure, SameSite: sameSite, Domain: domain, Path: "/", MaxAge: -1, }) c.Cookie(&fiber.Cookie{ Name: refreshName, Value: "", HTTPOnly: true, Secure: secure, SameSite: sameSite, Domain: domain, Path: "/api/v1/auth/refresh", MaxAge: -1, }) if domain != "" { c.Cookie(&fiber.Cookie{ Name: name, Value: "", HTTPOnly: true, Secure: secure, SameSite: sameSite, Path: "/", MaxAge: -1, }) c.Cookie(&fiber.Cookie{ Name: refreshName, Value: "", HTTPOnly: true, Secure: secure, SameSite: sameSite, Path: "/api/v1/auth/refresh", MaxAge: -1, }) } } func readValidAccessToken(c *fiber.Ctx, svc *service.AuthService) string { if c == nil || svc == nil { return "" } candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName()) for _, token := range candidates { if _, err := svc.ParseAccessToken(c.UserContext(), token); err == nil { return token } } return strings.TrimSpace(c.Cookies(svc.AccessCookieName())) } func readValidRefreshToken(c *fiber.Ctx, svc *service.AuthService) string { if c == nil || svc == nil { return "" } candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.RefreshCookieName()) if len(candidates) == 0 { return strings.TrimSpace(c.Cookies(svc.RefreshCookieName())) } return candidates[0] } func writeAuthErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error { enriched := make([]jsonapi.ErrorObject, 0, len(errs)) for i := range errs { e := errs[i] if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" { code, errorCode := inferAuthErrorCode(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = errorCode } } enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func inferAuthErrorCode(status int, title, detail string) (string, string) { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusAccepted: if strings.Contains(lowerTitle, "pin verification") || strings.Contains(lowerDetail, "pin action token") { return apperrorsx.CodePINVerificationRequired, apperrorsx.ErrPINVerificationRequired202.ErrorCode } return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode case fiber.StatusUnauthorized: if strings.Contains(lowerDetail, "token") || strings.Contains(lowerTitle, "invalid challenge") { return apperrorsx.CodeTokenInvalid, apperrorsx.ErrTokenInvalid.ErrorCode } if strings.Contains(lowerDetail, "pin") { return apperrorsx.CodePINIncorrect, apperrorsx.ErrPINIncorrect.ErrorCode } if strings.Contains(lowerDetail, "credentials") || strings.Contains(lowerTitle, "unauthorized") { return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode } return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode case fiber.StatusForbidden: if strings.Contains(lowerTitle, "registration disabled") { return apperrorsx.CodeAuthRegistrationDisabled, apperrorsx.ErrAuthRegistrationDisabled.ErrorCode } if strings.Contains(lowerDetail, "permission") || strings.Contains(lowerDetail, "forbidden") { return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode } return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode case fiber.StatusNotFound: if strings.Contains(lowerDetail, "user not found") || strings.Contains(lowerDetail, "session is not found") { return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode } return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode case fiber.StatusConflict: if strings.Contains(lowerDetail, "pin") { return apperrorsx.CodePINNotSet, apperrorsx.ErrPINNotSet.ErrorCode } return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode case fiber.StatusUnprocessableEntity: if strings.Contains(lowerDetail, "pin") { return apperrorsx.CodePINValidationFailed, apperrorsx.ErrPINValidationFailed.ErrorCode } if strings.Contains(lowerTitle, "validation") { return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode } return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode case fiber.StatusTooManyRequests: if strings.Contains(lowerDetail, "blocked") || strings.Contains(lowerTitle, "pin blocked") { return apperrorsx.CodePINBlocked, apperrorsx.ErrPINBlocked.ErrorCode } if strings.Contains(lowerDetail, "pin") || strings.Contains(lowerTitle, "too many attempts") { return apperrorsx.CodePINAttemptLimitReached, apperrorsx.ErrPINAttemptLimitReached.ErrorCode } return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode case fiber.StatusInternalServerError: if strings.Contains(lowerTitle, "reset password failed") { return apperrorsx.CodeAuthResetPasswordFailed, apperrorsx.ErrAuthResetPasswordFailed.ErrorCode } return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode case fiber.StatusBadRequest: if strings.Contains(lowerTitle, "invalid json") { return apperrorsx.CodeAuthInvalidJSON, apperrorsx.ErrAuthInvalidJSON.ErrorCode } if strings.Contains(lowerTitle, "role not found") { return apperrorsx.CodeAuthRoleNotFound, apperrorsx.ErrAuthRoleNotFound.ErrorCode } if strings.Contains(lowerTitle, "registration failed") { if strings.Contains(lowerDetail, "already registered") { return apperrorsx.CodeAuthUsernameAlreadyRegistered, apperrorsx.ErrAuthUsernameAlreadyRegistered.ErrorCode } return apperrorsx.CodeAuthRegistrationFailed, apperrorsx.ErrAuthRegistrationFailed.ErrorCode } if strings.Contains(lowerTitle, "invite failed") { return apperrorsx.CodeAuthInviteFailed, apperrorsx.ErrAuthInviteFailed.ErrorCode } if strings.Contains(lowerTitle, "set password failed") { if strings.Contains(lowerDetail, "invalid or expired token") { return apperrorsx.CodeAuthSetPasswordTokenInvalid, apperrorsx.ErrAuthSetPasswordTokenInvalid.ErrorCode } return apperrorsx.CodeAuthSetPasswordFailed, apperrorsx.ErrAuthSetPasswordFailed.ErrorCode } if strings.Contains(lowerTitle, "invalid reset link") { return apperrorsx.CodeAuthResetPasswordInvalidLink, apperrorsx.ErrAuthResetPasswordInvalidLink.ErrorCode } return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode default: return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode } } func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) { clearAuthCookieScopes(c, svc, parseSameSite(svc.CookieSameSite()), svc.CookieSecure()) } func clearAuthCookiesForFrontChannel(c *fiber.Ctx, svc *service.AuthService) { clearAuthCookieScopes(c, svc, "None", true) } func parseSameSite(v string) string { switch strings.ToLower(v) { case "strict": return "Strict" case "none": return "None" default: return "Lax" } } func readDeviceTypeHint(c *fiber.Ctx) string { if c == nil { return "" } return strings.TrimSpace(strings.ToLower(c.Get(deviceTypeHeader))) } func authUserFoto(ctx context.Context, storage fileManagerDownloadURLStorage, u *auth.User) *dto.UserFoto { if u == nil { return nil } fotoUUID := uuidBytesToStringOrEmpty(u.ProfileAttachmentID) fotoDownloadURL := "" fotoThumbnailURL := "" var fotoOriginalSizeBytes *int64 if u.ProfileAttachment != nil && u.ProfileAttachment.File != nil { asset := resolveFileManagerDownloadURLs(ctx, storage, u.ProfileAttachment.File) fotoDownloadURL = strings.TrimSpace(asset.OriginalURL) fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL) fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes) } if fotoUUID == "" && fotoDownloadURL == "" && fotoThumbnailURL == "" && fotoOriginalSizeBytes == nil { return nil } return &dto.UserFoto{ UUID: fotoUUID, DownloadURL: fotoDownloadURL, ThumbnailDownloadURL: fotoThumbnailURL, OriginalSizeBytes: fotoOriginalSizeBytes, } }