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(`