Files
fm_be/internal/transport/http/handlers/auth_handler.go
2026-07-16 22:16:45 +07:00

3609 lines
124 KiB
Go

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(`<!doctype html><meta charset="utf-8"><title>Logged out</title>`)
}
// 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,
}
}