From 7a4d0dd275985c7b2dea41f92354c2a3f583d7ac Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sun, 2 Aug 2026 23:36:06 +0200 Subject: [PATCH] refactor: standardize API error handling (#1635) --- backend/.golangci.yml | 2 +- backend/internal/api/handler.go | 82 ++--- backend/internal/api/module.go | 17 +- backend/internal/api/service.go | 47 ++- backend/internal/api/service_test.go | 23 +- backend/internal/apikey/handler.go | 44 ++- backend/internal/apikey/module.go | 9 +- backend/internal/apikey/service.go | 30 +- backend/internal/apikey/service_test.go | 21 ++ backend/internal/appconfig/service.go | 9 +- backend/internal/appconfig/service_test.go | 11 +- backend/internal/apperror/constructors.go | 289 +++++++++++++++ backend/internal/apperror/error.go | 234 ++++++++++++ backend/internal/apperror/error_test.go | 45 +++ .../internal/bootstrap/router_bootstrap.go | 34 ++ .../bootstrap/router_bootstrap_test.go | 80 +++++ backend/internal/common/env_config_test.go | 2 +- backend/internal/common/errors.go | 309 ---------------- .../controller/app_config_controller.go | 61 ++-- .../controller/app_images_controller.go | 126 ++++--- .../controller/audit_log_controller.go | 39 +- .../controller/custom_claim_controller.go | 41 +-- .../internal/controller/e2etest_controller.go | 71 ++-- .../internal/controller/oidc_controller.go | 223 ++++++------ .../internal/controller/scim_controller.go | 49 ++- .../internal/controller/user_controller.go | 198 +++++------ .../controller/user_group_controller.go | 104 +++--- .../internal/controller/version_controller.go | 15 +- .../controller/well_known_controller.go | 17 +- backend/internal/devicelogin/handler.go | 42 ++- backend/internal/devicelogin/module.go | 9 +- backend/internal/devicelogin/service.go | 25 +- backend/internal/devicelogin/service_test.go | 27 +- backend/internal/dto/dto_normalize.go | 105 +++--- backend/internal/dto/user_dto_test.go | 8 +- backend/internal/dto/validations.go | 10 + backend/internal/email/module.go | 6 +- backend/internal/email/module_test.go | 14 +- backend/internal/emailverification/handler.go | 21 +- backend/internal/emailverification/module.go | 5 +- backend/internal/emailverification/service.go | 8 +- .../emailverification/service_test.go | 11 +- backend/internal/httpserver/binding.go | 69 ++++ backend/internal/httpserver/binding_test.go | 85 +++++ backend/internal/httpserver/handler.go | 46 +++ backend/internal/httpserver/handler_test.go | 64 ++++ backend/internal/middleware/api_key_auth.go | 8 +- .../internal/middleware/auth_middleware.go | 10 +- backend/internal/middleware/error_handler.go | 335 +++++++++++++----- .../internal/middleware/error_handler_test.go | 180 ++++++++++ .../internal/middleware/file_size_limit.go | 12 +- .../middleware/file_size_limit_test.go | 66 ++++ backend/internal/middleware/jwt_auth.go | 18 +- backend/internal/middleware/rate_limit.go | 17 +- .../internal/middleware/rate_limit_test.go | 5 +- .../internal/oidc/authorization_handler.go | 23 +- .../internal/oidc/authorization_service.go | 19 +- .../oidc/authorization_service_test.go | 16 +- backend/internal/oidc/device_handler.go | 6 +- backend/internal/oidc/device_service.go | 33 +- backend/internal/oidc/device_service_test.go | 20 +- backend/internal/oidc/end_session_service.go | 21 +- .../internal/oidc/end_session_service_test.go | 28 +- .../internal/oidc/federated_client_auth.go | 2 +- .../oidc/introspection_handler_test.go | 5 +- backend/internal/oidc/preview_test.go | 6 +- backend/internal/oidc/provider_test.go | 27 +- backend/internal/onetimeaccess/handler.go | 60 ++-- backend/internal/onetimeaccess/module.go | 9 +- backend/internal/onetimeaccess/service.go | 17 +- .../internal/onetimeaccess/service_test.go | 11 +- .../internal/service/app_images_service.go | 10 +- .../service/app_images_service_test.go | 11 +- .../internal/service/custom_claim_service.go | 59 ++- .../service/custom_claim_service_test.go | 19 + backend/internal/service/ldap_service.go | 11 +- backend/internal/service/ldap_service_test.go | 9 + backend/internal/service/oidc_service.go | 129 ++++--- backend/internal/service/oidc_service_test.go | 22 +- backend/internal/service/scim_service.go | 63 +++- backend/internal/service/scim_service_test.go | 61 ++++ .../internal/service/user_group_service.go | 19 +- backend/internal/service/user_service.go | 43 ++- backend/internal/service/user_service_test.go | 45 +++ backend/internal/usersignup/handler.go | 75 ++-- backend/internal/usersignup/migration.go | 2 +- backend/internal/usersignup/module.go | 13 +- backend/internal/usersignup/service.go | 7 +- backend/internal/usersignup/service_test.go | 8 +- .../internal/utils/callback_url_util_test.go | 5 +- backend/internal/utils/cookie/cookie_names.go | 6 +- .../internal/utils/image/profile_picture.go | 8 +- backend/internal/webauthn/handler.go | 92 +++-- backend/internal/webauthn/module.go | 19 +- backend/internal/webauthn/service.go | 82 ++++- backend/internal/webauthn/service_test.go | 70 +++- frontend/messages/en.json | 20 +- frontend/src/hooks.client.ts | 10 +- frontend/src/lib/utils/error-util.ts | 63 +++- frontend/src/routes/+layout.ts | 8 +- .../login/alternative/email/+page.svelte | 3 +- tests/specs/account-settings.spec.ts | 4 +- tests/specs/api.spec.ts | 4 +- tests/specs/application-configuration.spec.ts | 2 +- tests/specs/oidc-client-settings.spec.ts | 1 + tests/specs/oidc.spec.ts | 20 +- tests/specs/scim.spec.ts | 26 +- tests/specs/user-signup.spec.ts | 2 +- 108 files changed, 3163 insertions(+), 1629 deletions(-) create mode 100644 backend/internal/apikey/service_test.go create mode 100644 backend/internal/apperror/constructors.go create mode 100644 backend/internal/apperror/error.go create mode 100644 backend/internal/apperror/error_test.go create mode 100644 backend/internal/bootstrap/router_bootstrap_test.go delete mode 100644 backend/internal/common/errors.go create mode 100644 backend/internal/httpserver/binding.go create mode 100644 backend/internal/httpserver/binding_test.go create mode 100644 backend/internal/httpserver/handler.go create mode 100644 backend/internal/httpserver/handler_test.go create mode 100644 backend/internal/middleware/error_handler_test.go create mode 100644 backend/internal/middleware/file_size_limit_test.go create mode 100644 backend/internal/service/custom_claim_service_test.go create mode 100644 backend/internal/service/scim_service_test.go diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 5b0f1919..b00be316 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -64,4 +64,4 @@ formatters: paths: - third_party$ - builtin$ - - examples$ \ No newline at end of file + - examples$ diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index ba64b02b..db16ea85 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils" ) @@ -29,22 +30,20 @@ func newHandler(service *Service) *handler { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[apiResponseDto] // @Router /api/apis [get] -func (h *handler) list(c *gin.Context) { +func (h *handler) list(c *gin.Context) error { search := c.Query("search") listRequestOptions := utils.ParseListRequestOptions(c) apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } items := make([]apiResponseDto, len(apis)) for i, api := range apis { var item apiResponseDto if err := dto.MapStruct(api, &item); err != nil { - _ = c.Error(err) - return + return err } item.Resource = api.Audience items[i] = item @@ -54,6 +53,7 @@ func (h *handler) list(c *gin.Context) { Data: items, Pagination: pagination, }) + return nil } // get godoc @@ -64,14 +64,13 @@ func (h *handler) list(c *gin.Context) { // @Param id path string true "API ID" // @Success 200 {object} apiResponseDto // @Router /api/apis/{id} [get] -func (h *handler) get(c *gin.Context) { +func (h *handler) get(c *gin.Context) error { api, err := h.service.Get(c.Request.Context(), nil, c.Param("id")) if err != nil { - _ = c.Error(err) - return + return err } - h.respond(c, http.StatusOK, api) + return h.respond(c, http.StatusOK, api) } // create godoc @@ -83,20 +82,18 @@ func (h *handler) get(c *gin.Context) { // @Param api body apiCreateDto true "API information" // @Success 201 {object} apiResponseDto "Created API" // @Router /api/apis [post] -func (h *handler) create(c *gin.Context) { +func (h *handler) create(c *gin.Context) error { var input apiCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } api, err := h.service.Create(c.Request.Context(), input) if err != nil { - _ = c.Error(err) - return + return err } - h.respond(c, http.StatusCreated, api) + return h.respond(c, http.StatusCreated, api) } // update godoc @@ -109,20 +106,18 @@ func (h *handler) create(c *gin.Context) { // @Param api body apiUpdateDto true "API information" // @Success 200 {object} apiResponseDto "Updated API" // @Router /api/apis/{id} [put] -func (h *handler) update(c *gin.Context) { +func (h *handler) update(c *gin.Context) error { var input apiUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } api, err := h.service.Update(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } - h.respond(c, http.StatusOK, api) + return h.respond(c, http.StatusOK, api) } // delete godoc @@ -132,13 +127,13 @@ func (h *handler) update(c *gin.Context) { // @Param id path string true "API ID" // @Success 204 "No Content" // @Router /api/apis/{id} [delete] -func (h *handler) delete(c *gin.Context) { +func (h *handler) delete(c *gin.Context) error { if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updatePermissions godoc @@ -151,20 +146,18 @@ func (h *handler) delete(c *gin.Context) { // @Param permissions body apiPermissionsUpdateDto true "Permissions to set" // @Success 200 {object} apiResponseDto "Updated API" // @Router /api/apis/{id}/permissions [put] -func (h *handler) updatePermissions(c *gin.Context) { +func (h *handler) updatePermissions(c *gin.Context) error { var input apiPermissionsUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } - h.respond(c, http.StatusOK, api) + return h.respond(c, http.StatusOK, api) } // getClientAccess godoc @@ -175,14 +168,14 @@ func (h *handler) updatePermissions(c *gin.Context) { // @Param clientId path string true "OIDC Client ID" // @Success 200 {object} clientApiAccessDto // @Router /api/api-access/{clientId} [get] -func (h *handler) getClientAccess(c *gin.Context) { +func (h *handler) getClientAccess(c *gin.Context) error { access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId")) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, newClientApiAccessDto(access)) + return nil } // updateClientAccess godoc @@ -195,21 +188,20 @@ func (h *handler) getClientAccess(c *gin.Context) { // @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type" // @Success 200 {object} clientApiAccessDto // @Router /api/api-access/{clientId} [put] -func (h *handler) updateClientAccess(c *gin.Context) { +func (h *handler) updateClientAccess(c *gin.Context) error { var input clientApiAccessUpdateDto - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input)) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, newClientApiAccessDto(applied)) + return nil } // newClientApiAccessDto always serializes both permission lists as arrays rather than null @@ -224,12 +216,12 @@ func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto { return dto } -func (h *handler) respond(c *gin.Context, status int, api API) { +func (h *handler) respond(c *gin.Context, status int, api API) error { var responseDto apiResponseDto if err := dto.MapStruct(api, &responseDto); err != nil { - _ = c.Error(err) - return + return err } responseDto.Resource = api.Audience c.JSON(status, responseDto) + return nil } diff --git a/backend/internal/api/module.go b/backend/internal/api/module.go index 68da6cdb..00baf1d6 100644 --- a/backend/internal/api/module.go +++ b/backend/internal/api/module.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/oidc" ) @@ -63,16 +64,16 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) { apis := apiGroup.Group("/apis") apis.Use(adminAuth) - apis.GET("", m.handler.list) - apis.POST("", m.handler.create) - apis.GET("/:id", m.handler.get) - apis.PUT("/:id", m.handler.update) - apis.DELETE("/:id", m.handler.delete) - apis.PUT("/:id/permissions", m.handler.updatePermissions) + apis.GET("", httpserver.Handle(m.handler.list)) + apis.POST("", httpserver.Handle(m.handler.create)) + apis.GET("/:id", httpserver.Handle(m.handler.get)) + apis.PUT("/:id", httpserver.Handle(m.handler.update)) + apis.DELETE("/:id", httpserver.Handle(m.handler.delete)) + apis.PUT("/:id/permissions", httpserver.Handle(m.handler.updatePermissions)) // The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard access := apiGroup.Group("/api-access") access.Use(adminAuth) - access.GET("/:clientId", m.handler.getClientAccess) - access.PUT("/:clientId", m.handler.updateClientAccess) + access.GET("/:clientId", httpserver.Handle(m.handler.getClientAccess)) + access.PUT("/:clientId", httpserver.Handle(m.handler.updateClientAccess)) } diff --git a/backend/internal/api/service.go b/backend/internal/api/service.go index 05b18d3d..52cb9919 100644 --- a/backend/internal/api/service.go +++ b/backend/internal/api/service.go @@ -8,7 +8,7 @@ import ( "time" "github.com/ory/fosite" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" @@ -81,13 +81,16 @@ func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err Where("id = ?", id). First(&api). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return API{}, apperror.NotFound("API") + } return api, err } func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) { // Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens if isIssuerAudience(input.Resource, s.issuer) { - return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"} + return API{}, apperror.InvalidField("resource", "reserved", "is reserved by Pocket ID and cannot be used for a custom API") } api = API{ @@ -98,7 +101,7 @@ func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err err = s.db.WithContext(ctx).Create(&api).Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - return API{}, &common.AlreadyInUseError{Property: "resource"} + return API{}, apperror.AlreadyInUse("resource") } return API{}, err } @@ -170,16 +173,17 @@ func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPer // Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything // A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200 seen := make(map[string]struct{}, len(input.Permissions)) - for _, permission := range input.Permissions { + for index, permission := range input.Permissions { + field := fmt.Sprintf("permissions[%d].key", index) if !isValidPermissionKey(permission.Key) { - return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)} + return API{}, apperror.InvalidField(field, "invalid_format", "contains characters that are not valid in an OAuth scope") } if isPermissionKeyReserved(permission.Key) { - return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)} + return API{}, apperror.InvalidField(field, "reserved", "is reserved by Pocket ID") } _, ok := seen[permission.Key] if ok { - return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)} + return API{}, apperror.InvalidField(field, "duplicate", "is listed more than once") } seen[permission.Key] = struct{}{} } @@ -261,8 +265,17 @@ type ClientAPIAccess struct { // GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type // Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + if err := ensureOIDCClientExists(ctx, tx, clientID); err != nil { + return ClientAPIAccess{}, err + } + var rows []OidcClientAllowedAPIPermission - err = s.db.WithContext(ctx). + err = tx.WithContext(ctx). Where("oidc_client_id = ?", clientID). Find(&rows). Error @@ -293,9 +306,7 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces tx.Rollback() }() - // Ensure the client exists so callers get a 404 for an unknown client - var client model.OidcClient - if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil { + if err = ensureOIDCClientExists(ctx, tx, clientID); err != nil { return ClientAPIAccess{}, err } @@ -337,6 +348,20 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces return applied, nil } +func ensureOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error { + var client model.OidcClient + err := db.WithContext(ctx). + Select("id"). + Where("id = ?", clientID). + First(&client). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("OIDC client") + } + + return err +} + // ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types // The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) { diff --git a/backend/internal/api/service_test.go b/backend/internal/api/service_test.go index 2ba31c85..cb7c7f05 100644 --- a/backend/internal/api/service_test.go +++ b/backend/internal/api/service_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/oidc" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -22,7 +22,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) { // The resource is unique. _, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"}) - require.ErrorIs(t, err, &common.AlreadyInUseError{}) + require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse)) desc := "Read orders" updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ @@ -59,7 +59,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) { require.NoError(t, svc.Delete(t.Context(), created.ID)) _, err = svc.Get(t.Context(), nil, created.ID) - require.Error(t, err) + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) } func TestClientApiAccessAllowList(t *testing.T) { @@ -121,7 +121,10 @@ func TestClientApiAccessAllowList(t *testing.T) { // An unknown client is rejected (surfaces as 404 at the HTTP layer). _, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}}) - require.Error(t, err) + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + + _, err = svc.GetClientAPIAccess(t.Context(), "nope") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) } // TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow @@ -177,8 +180,7 @@ func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) { {Key: key, Name: "Reserved"}, }}) require.Error(t, err, "key %q must be rejected", key) - var validationErr *common.ValidationError - require.ErrorAs(t, err, &validationErr) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } } @@ -195,8 +197,7 @@ func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) { {Key: "read:orders", Name: "Read again"}, }}) require.Error(t, err) - var validationErr *common.ValidationError - require.ErrorAs(t, err, &validationErr) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) { @@ -212,8 +213,7 @@ func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) { {Key: key, Name: "Invalid"}, }}) require.Error(t, err, "key %q must be rejected", key) - var validationErr *common.ValidationError - require.ErrorAs(t, err, &validationErr) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } // A valid scope-token key is accepted @@ -232,8 +232,7 @@ func TestCreateRejectsIssuerResource(t *testing.T) { for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} { _, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource}) require.Error(t, err, "resource %q must be rejected", resource) - var validationErr *common.ValidationError - require.ErrorAs(t, err, &validationErr) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } // A normal resource is accepted diff --git a/backend/internal/apikey/handler.go b/backend/internal/apikey/handler.go index d11caf0d..347670d2 100644 --- a/backend/internal/apikey/handler.go +++ b/backend/internal/apikey/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils" ) @@ -27,27 +28,26 @@ func newHandler(service *Service) *handler { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[apiKeyDto] // @Router /api/api-keys [get] -func (h *handler) list(c *gin.Context) { +func (h *handler) list(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) userID := c.GetString("userID") apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } var apiKeysDto []apiKeyDto if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{ Data: apiKeysDto, Pagination: pagination, }) + return nil } // create godoc @@ -57,31 +57,29 @@ func (h *handler) list(c *gin.Context) { // @Param api_key body apiKeyCreateDto true "API key information" // @Success 201 {object} apiKeyResponseDto "Created API key with token" // @Router /api/api-keys [post] -func (h *handler) create(c *gin.Context) { +func (h *handler) create(c *gin.Context) error { userID := c.GetString("userID") var input apiKeyCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input) if err != nil { - _ = c.Error(err) - return + return err } var responseDto apiKeyDto if err := dto.MapStruct(apiKey, &responseDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, apiKeyResponseDto{ ApiKey: responseDto, Token: token, }) + return nil } // renew godoc @@ -91,32 +89,30 @@ func (h *handler) create(c *gin.Context) { // @Param id path string true "API Key ID" // @Success 200 {object} apiKeyResponseDto "Renewed API key with new token" // @Router /api/api-keys/{id}/renew [post] -func (h *handler) renew(c *gin.Context) { +func (h *handler) renew(c *gin.Context) error { userID := c.GetString("userID") apiKeyID := c.Param("id") var input apiKeyRenewDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime()) if err != nil { - _ = c.Error(err) - return + return err } var responseDto apiKeyDto if err := dto.MapStruct(apiKey, &responseDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, apiKeyResponseDto{ ApiKey: responseDto, Token: token, }) + return nil } // revoke godoc @@ -126,14 +122,14 @@ func (h *handler) renew(c *gin.Context) { // @Param id path string true "API Key ID" // @Success 204 "No Content" // @Router /api/api-keys/{id} [delete] -func (h *handler) revoke(c *gin.Context) { +func (h *handler) revoke(c *gin.Context) error { userID := c.GetString("userID") apiKeyID := c.Param("id") if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/apikey/module.go b/backend/internal/apikey/module.go index dbc18afc..87e0c2d0 100644 --- a/backend/internal/apikey/module.go +++ b/backend/internal/apikey/module.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -35,10 +36,10 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) { // authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) { group := apiGroup.Group("/api-keys") - group.GET("", auth, m.handler.list) - group.POST("", authWithoutApiKey, m.handler.create) - group.POST("/:id/renew", authWithoutApiKey, m.handler.renew) - group.DELETE("/:id", auth, m.handler.revoke) + group.GET("", auth, httpserver.Handle(m.handler.list)) + group.POST("", authWithoutApiKey, httpserver.Handle(m.handler.create)) + group.POST("/:id/renew", authWithoutApiKey, httpserver.Handle(m.handler.renew)) + group.DELETE("/:id", auth, httpserver.Handle(m.handler.revoke)) } // ValidateApiKey resolves the user that owns the given raw API key diff --git a/backend/internal/apikey/service.go b/backend/internal/apikey/service.go index 581106c3..8efed02e 100644 --- a/backend/internal/apikey/service.go +++ b/backend/internal/apikey/service.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -54,7 +55,7 @@ func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOpt func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) { // Check if expiration is in the future if !input.ExpiresAt.ToTime().After(time.Now()) { - return ApiKey{}, "", &common.APIKeyExpirationDateError{} + return ApiKey{}, "", apperror.InvalidAPIKeyExpiration() } // Generate a secure random API key @@ -77,7 +78,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"} + return ApiKey{}, "", apperror.AlreadyInUse("API key name") } return ApiKey{}, "", err } @@ -89,7 +90,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) { // Check if expiration is in the future if !expiration.After(time.Now()) { - return ApiKey{}, "", &common.APIKeyExpirationDateError{} + return ApiKey{}, "", apperror.InvalidAPIKeyExpiration() } tx := s.db.Begin() @@ -105,14 +106,14 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return ApiKey{}, "", &common.APIKeyNotFoundError{} + return ApiKey{}, "", apperror.APIKeyNotFound() } return ApiKey{}, "", err } // Only allow renewal if the key has already expired if apiKey.ExpiresAt.ToTime().After(time.Now()) { - return ApiKey{}, "", &common.APIKeyNotExpiredError{} + return ApiKey{}, "", apperror.APIKeyNotExpired() } // Generate a secure random API key @@ -138,16 +139,15 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error { var apiKey ApiKey - err := s.db. + result := s.db. WithContext(ctx). Where("id = ? AND user_id = ?", apiKeyID, userID). - Delete(&apiKey). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return &common.APIKeyNotFoundError{} - } - return err + Delete(&apiKey) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return apperror.APIKeyNotFound() } return nil @@ -155,7 +155,7 @@ func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) err func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) { if apiKey == "" { - return model.User{}, &common.NoAPIKeyProvidedError{} + return model.User{}, apperror.NoAPIKeyProvided() } if s.staticApiKey != "" && apiKey == s.staticApiKey { @@ -179,7 +179,7 @@ func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return model.User{}, &common.InvalidAPIKeyError{} + return model.User{}, apperror.InvalidAPIKey() } return model.User{}, err diff --git a/backend/internal/apikey/service_test.go b/backend/internal/apikey/service_test.go new file mode 100644 index 00000000..e6015637 --- /dev/null +++ b/backend/internal/apikey/service_test.go @@ -0,0 +1,21 @@ +package apikey + +import ( + "testing" + "time" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/require" +) + +func TestMissingManagedAPIKeyReturnsNotFound(t *testing.T) { + service, err := newService(t.Context(), testutils.NewDatabaseForTest(t), "") + require.NoError(t, err) + + err = service.RevokeApiKey(t.Context(), "user-id", "missing-key") + require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound)) + + _, _, err = service.RenewApiKey(t.Context(), "user-id", "missing-key", time.Now().Add(time.Hour)) + require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound)) +} diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go index d4c0ed55..4e367b6c 100644 --- a/backend/internal/appconfig/service.go +++ b/backend/internal/appconfig/service.go @@ -14,6 +14,7 @@ import ( "github.com/italypaleale/francis/host/local" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/tracing" @@ -117,18 +118,18 @@ func (s *AppConfigService) GetCIMDURLAllowlist() []string { func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) { // If the UI config is disabled, we cannot continue if common.EnvConfig.UiConfigDisabled { - return nil, &common.UiConfigDisabledError{} + return nil, apperror.UIConfigDisabled() } // Validate the CIMD URL allowlist patterns, if provided if input.CIMDURLAllowlist != "" { var patterns []string if err := json.Unmarshal([]byte(input.CIMDURLAllowlist), &patterns); err != nil { - return nil, &common.InvalidCIMDURLPatternError{Pattern: input.CIMDURLAllowlist} + return nil, apperror.InvalidCIMDURLPattern(input.CIMDURLAllowlist) } for _, p := range patterns { if err := utils.ValidateCallbackURLPattern(p); err != nil { - return nil, &common.InvalidCIMDURLPatternError{Pattern: p} + return nil, apperror.InvalidCIMDURLPattern(p) } } } @@ -154,7 +155,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal // If the UI config is disabled, we cannot continue if common.EnvConfig.UiConfigDisabled { - return &common.UiConfigDisabledError{} + return apperror.UIConfigDisabled() } // Collect the key-value pairs into a map for the actor diff --git a/backend/internal/appconfig/service_test.go b/backend/internal/appconfig/service_test.go index f06bf7d0..83f34090 100644 --- a/backend/internal/appconfig/service_test.go +++ b/backend/internal/appconfig/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -199,14 +200,13 @@ func TestService_UpdateAppConfig(t *testing.T) { assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls) }) - t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) { + t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) { setUIConfigDisabled(t, true) svc := NewTestAppConfigService(nil) _, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"}) require.Error(t, err) - var target *common.UiConfigDisabledError - assert.ErrorAs(t, err, &target) + assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled)) }) } @@ -269,15 +269,14 @@ func TestService_UpdateAppConfigValues(t *testing.T) { assert.Equal(t, *getDefaultConfig(), *cfg) }) - t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) { + t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) { setUIConfigDisabled(t, true) svc := NewTestAppConfigService(nil) // An even number of arguments so the count check passes and we reach the UI-config check err := svc.UpdateAppConfigValues(t.Context(), "appName", "X") require.Error(t, err) - var target *common.UiConfigDisabledError - assert.ErrorAs(t, err, &target) + assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled)) }) } diff --git a/backend/internal/apperror/constructors.go b/backend/internal/apperror/constructors.go new file mode 100644 index 00000000..96c8e52e --- /dev/null +++ b/backend/internal/apperror/constructors.go @@ -0,0 +1,289 @@ +package apperror + +import ( + "fmt" + "net/http" +) + +// The constructors in this file keep public messages, statuses, and details in one place + +func AlreadyInUse(property string) *Error { + return New(CodeAlreadyInUse, http.StatusConflict, property+" is already in use").WithDetail("property", property) +} + +func NotFound(resource string) *Error { + return New(CodeNotFound, http.StatusNotFound, resource+" not found").WithDetail("resource", resource) +} + +func InvalidRequestBody(cause error) *Error { + return Wrap(cause, CodeInvalidRequestBody, http.StatusBadRequest, "Request body is invalid") +} + +func MissingField(field string) *Error { + return InvalidField(field, "required", "is required") +} + +func InvalidField(field, code, message string) *Error { + return New(CodeValidationFailed, http.StatusBadRequest, fmt.Sprintf("%s %s", field, message)).WithFields([]FieldError{{ + Field: field, + Code: code, + Message: message, + }}) +} + +func SetupNotAvailable() *Error { + return New(CodeSetupNotAvailable, http.StatusNotFound, "Not found") +} + +func SetupAlreadyCompleted() *Error { + return New(CodeSetupAlreadyCompleted, http.StatusConflict, "Initial setup has already been completed") +} + +func TokenInvalidOrExpired() *Error { + return New(CodeTokenInvalidOrExpired, http.StatusUnauthorized, "Token is invalid or expired") +} + +func DeviceCodeInvalid() *Error { + return New(CodeDeviceCodeInvalid, http.StatusUnauthorized, "One-time access code must be used on the device it was generated for") +} + +func TokenInvalid() *Error { + return New(CodeInvalidToken, http.StatusUnauthorized, "Token is invalid") +} + +func OidcMissingAuthorization() *Error { + return New(CodeOidcMissingAuthorization, http.StatusForbidden, "Authorization is missing") +} + +func OidcInvalidCallbackURL() *Error { + return New(CodeOidcInvalidCallbackURL, http.StatusBadRequest, "Callback URL is invalid and may need to be corrected by an administrator") +} + +func InvalidCIMDURLPattern(pattern string) *Error { + return New(CodeValidationFailed, http.StatusBadRequest, "Metadata document URL pattern is invalid"). + WithDetail("pattern", pattern). + WithFields([]FieldError{{ + Field: "cimdUrlAllowlist", + Code: "invalid_value", + Message: "contains an invalid URL pattern", + }}) +} + +func UnsupportedFileType(expected string) *Error { + if expected == "" { + return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File type is not supported") + } + + return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File must be of type "+expected). + WithDetail("expected_file_type", expected) +} + +func FileTooLarge(maxSize string) *Error { + return New(CodeFileTooLarge, http.StatusRequestEntityTooLarge, fmt.Sprintf("File must not exceed %s", maxSize)).WithDetail("max_size", maxSize) +} + +func NotSignedIn() *Error { + return New(CodeNotSignedIn, http.StatusUnauthorized, "You are not signed in") +} + +func MissingPermission() *Error { + return New(CodeForbidden, http.StatusForbidden, "You don't have permission to perform this action") +} + +func TooManyRequests() *Error { + return New(CodeRateLimited, http.StatusTooManyRequests, "Too many requests") +} + +func UserNotFound() *Error { + return New(CodeUserNotFound, http.StatusNotFound, "User not found") +} + +func InvalidImage(cause error) *Error { + return Wrap(cause, CodeInvalidImage, http.StatusBadRequest, "File is not a valid image") +} + +func MissingSessionID() *Error { + return New(CodeMissingSessionID, http.StatusBadRequest, "Session ID is missing") +} + +func InvalidWebAuthnSession() *Error { + return New(CodeInvalidWebAuthnSession, http.StatusBadRequest, "Your passkey request has expired") +} + +func InvalidWebAuthnResponse(cause error) *Error { + return Wrap(cause, CodeInvalidWebAuthnResponse, http.StatusBadRequest, "We couldn't process the response from your passkey") +} + +func WebAuthnAuthenticationFailed(cause error) *Error { + return Wrap(cause, CodeWebAuthnAuthenticationFailed, http.StatusUnauthorized, "We couldn't verify your passkey") +} + +func PasskeyUserVerificationRequired(cause error) *Error { + return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again") +} + +func ReservedClaim(key string) *Error { + return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)). + WithDetail("key", key). + WithFields([]FieldError{{ + Field: "key", + Code: "reserved", + Message: "is reserved", + }}) +} + +func DuplicateClaim(key string) *Error { + return New(CodeDuplicateClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is already defined", key)). + WithDetail("key", key). + WithFields([]FieldError{{ + Field: "key", + Code: "duplicate", + Message: "is listed more than once", + }}) +} + +func LdapDisabled() *Error { + return New(CodeLdapDisabled, http.StatusConflict, "LDAP is not enabled") +} + +func LdapUserUpdate() *Error { + return New(CodeLdapUserUpdate, http.StatusForbidden, "LDAP users can't be updated") +} + +func LdapUserGroupUpdate() *Error { + return New(CodeLdapUserGroupUpdate, http.StatusForbidden, "LDAP user groups can't be updated") +} + +func OidcAccessDenied() *Error { + return New(CodeOidcAccessDenied, http.StatusForbidden, "You're not allowed to access this service") +} + +func OidcInteractionNotFound() *Error { + return New(CodeNotFound, http.StatusNotFound, "OIDC interaction not found or expired"). + WithDetail("resource", "OIDC interaction") +} + +func OidcClientIDNotMatching() *Error { + return New(CodeOidcClientIDNotMatching, http.StatusBadRequest, "Client ID in request doesn't match client ID in token") +} + +func UIConfigDisabled() *Error { + return New(CodeUIConfigDisabled, http.StatusForbidden, "The configuration can't be changed since the UI configuration is disabled") +} + +func InvalidUserID() *Error { + return Validation([]FieldError{{ + Field: "userId", + Code: "invalid_format", + Message: "must be a valid UUID", + }}) +} + +func OneTimeAccessDisabled() *Error { + return New(CodeOneTimeAccessDisabled, http.StatusForbidden, "One-time access is disabled") +} + +func DeviceLoginRequestInvalidOrExpired() *Error { + return New(CodeDeviceLoginExpired, http.StatusUnauthorized, "Device login request is invalid or expired") +} + +func DeviceLoginDenied() *Error { + return New(CodeDeviceLoginDenied, http.StatusForbidden, "Device login request was denied") +} + +func InvalidAPIKey() *Error { + return New(CodeInvalidAPIKey, http.StatusUnauthorized, "API key is invalid") +} + +func NoAPIKeyProvided() *Error { + return New(CodeNoAPIKeyProvided, http.StatusUnauthorized, "API key is missing") +} + +func APIKeyNotFound() *Error { + return New(CodeAPIKeyNotFound, http.StatusNotFound, "API key not found") +} + +func APIKeyNotExpired() *Error { + return New(CodeAPIKeyNotExpired, http.StatusConflict, "API key is not expired yet") +} + +func InvalidAPIKeyExpiration() *Error { + return New(CodeInvalidAPIKeyExpiration, http.StatusBadRequest, "API key expiration time must be in the future").WithFields([]FieldError{{ + Field: "expiresAt", + Code: "invalid_value", + Message: "must be in the future", + }}) +} + +func APIKeyAuthNotAllowed() *Error { + return New(CodeAPIKeyAuthNotAllowed, http.StatusForbidden, "API key authentication is not allowed for this endpoint") +} + +func UserDisabled() *Error { + return New(CodeUserDisabled, http.StatusForbidden, "User account is disabled") +} + +func ValidationMessage(message string) *Error { + return New(CodeValidationFailed, http.StatusBadRequest, message) +} + +func OidcDeviceCodeExpired() *Error { + return New(CodeOidcDeviceCodeExpired, http.StatusBadRequest, "Device code has expired") +} + +func OidcInvalidDeviceCode() *Error { + return New(CodeOidcInvalidDeviceCode, http.StatusBadRequest, "Device code is invalid") +} + +func ReauthenticationRequired() *Error { + return New(CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required") +} + +func ReauthenticationRequiredWithCause(cause error) *Error { + return Wrap(cause, CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required") +} + +func OpenSignupDisabled() *Error { + return New(CodeOpenSignupDisabled, http.StatusForbidden, "Open user signup is not enabled") +} + +func ClientIDAlreadyExists() *Error { + return New(CodeClientIDAlreadyExists, http.StatusConflict, "Client ID is already in use") +} + +func UserEmailNotSet() *Error { + return New(CodeUserEmailNotSet, http.StatusConflict, "The user does not have an email address set") +} + +func ImageNotFound() *Error { + return New(CodeImageNotFound, http.StatusNotFound, "Image not found") +} + +func LogoDownloadFailed(cause error) *Error { + return Wrap(cause, CodeLogoDownloadFailed, http.StatusUnprocessableEntity, "Logo could not be downloaded") +} + +func LogoTypeNotSupported() *Error { + return New(CodeLogoTypeNotSupported, http.StatusUnprocessableEntity, "Downloaded logo has an unsupported file type") +} + +func LogoTooLarge(maxSize string) *Error { + return New(CodeLogoTooLarge, http.StatusUnprocessableEntity, fmt.Sprintf("Downloaded logo must not exceed %s", maxSize)). + WithDetail("max_size", maxSize) +} + +func InvalidLogoURL(cause error) *Error { + return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{ + Field: "logoUrl", + Code: "invalid_value", + Message: "must be a public HTTP or HTTPS URL", + }}) +} + +func OidcPARRequired() *Error { + return New(CodeOidcPARRequired, http.StatusBadRequest, "This client requires pushed authorization requests") +} + +func InvalidEmailVerificationToken() *Error { + return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid") +} diff --git a/backend/internal/apperror/error.go b/backend/internal/apperror/error.go new file mode 100644 index 00000000..22d4ba6c --- /dev/null +++ b/backend/internal/apperror/error.go @@ -0,0 +1,234 @@ +package apperror + +import ( + "errors" + "fmt" + "maps" + "net/http" + "time" +) + +type Code string + +// #nosec G101 -- these are client-visible error codes, not credentials +const ( + CodeInternal Code = "internal_error" + CodeValidationFailed Code = "validation_failed" + CodeInvalidRequestBody Code = "invalid_request_body" + CodeRequestTimeout Code = "request_timeout" + CodeNotFound Code = "not_found" + CodeAlreadyInUse Code = "already_in_use" + CodeForbidden Code = "forbidden" + CodeInvalidToken Code = "invalid_token" + CodeRateLimited Code = "rate_limited" + CodeFileTooLarge Code = "file_too_large" + CodeInvalidImage Code = "invalid_image" + CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response" + CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed" + CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required" + CodeInvalidWebAuthnSession Code = "invalid_webauthn_session" + CodeUserNotFound Code = "user_not_found" + CodeUserDisabled Code = "user_disabled" + CodeAPIKeyNotFound Code = "api_key_not_found" + CodeInvalidAPIKey Code = "invalid_api_key" + CodeDeviceLoginExpired Code = "device_login_expired" + CodeReauthenticationRequired Code = "reauthentication_required" + CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token" + CodeSetupNotAvailable Code = "setup_not_available" + CodeSetupAlreadyCompleted Code = "setup_already_completed" + CodeTokenInvalidOrExpired Code = "token_invalid_or_expired" + CodeDeviceCodeInvalid Code = "device_code_invalid" + CodeOidcMissingAuthorization Code = "oidc_missing_authorization" + CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url" + CodeFileTypeNotSupported Code = "file_type_not_supported" + CodeNotSignedIn Code = "not_signed_in" + CodeMissingSessionID Code = "missing_session_id" + CodeReservedClaim Code = "reserved_claim" + CodeDuplicateClaim Code = "duplicate_claim" + CodeLdapDisabled Code = "ldap_disabled" + CodeLdapUserUpdate Code = "ldap_user_update" + CodeLdapUserGroupUpdate Code = "ldap_user_group_update" + CodeOidcAccessDenied Code = "oidc_access_denied" + CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching" + CodeUIConfigDisabled Code = "ui_config_disabled" + CodeOneTimeAccessDisabled Code = "one_time_access_disabled" + CodeDeviceLoginDenied Code = "device_login_denied" + CodeNoAPIKeyProvided Code = "no_api_key_provided" + CodeAPIKeyNotExpired Code = "api_key_not_expired" + CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration" + CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed" + CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired" + CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code" + CodeOpenSignupDisabled Code = "open_signup_disabled" + CodeClientIDAlreadyExists Code = "client_id_already_exists" + CodeUserEmailNotSet Code = "user_email_not_set" + CodeImageNotFound Code = "image_not_found" + CodeLogoDownloadFailed Code = "logo_download_failed" + CodeLogoTypeNotSupported Code = "logo_type_not_supported" + CodeLogoTooLarge Code = "logo_too_large" + CodeOidcPARRequired Code = "oidc_par_required" +) + +// FieldError describes one safe, client-actionable validation failure +type FieldError struct { + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` +} + +// Error is an application error with a stable code, an HTTP status, and an optional internal cause +type Error struct { + code Code + status int + message string + details map[string]string + fields []FieldError + retryAfter time.Duration + cause error +} + +// New creates a client-safe application error +func New(code Code, status int, message string) *Error { + return &Error{code: code, status: status, message: message} +} + +// Wrap creates a client-safe application error that retains an internal cause +func Wrap(cause error, code Code, status int, message string) *Error { + if cause == nil { + return New(code, status, message) + } + + return &Error{code: code, status: status, message: message, cause: cause} +} + +// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients +func Internal(cause error) *Error { + return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong") +} + +// Validation creates a structured validation error +func Validation(fields []FieldError) *Error { + return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields) +} + +// Error returns diagnostic text including the internal cause when one exists +func (e *Error) Error() string { + if e == nil { + return "" + } + if e.cause == nil { + return e.message + } + + return fmt.Sprintf("%s: %v", e.message, e.cause) +} + +// Unwrap exposes the internal cause to errors.Is and errors.As +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + + return e.cause +} + +// Is matches application errors by stable code while ignoring message and detail differences +func (e *Error) Is(target error) bool { + targetError, ok := target.(*Error) + return ok && e != nil && targetError != nil && e.code == targetError.code +} + +// IsCode reports whether an error or one of its wrapped causes has the given application code +func IsCode(err error, code Code) bool { + return errors.Is(err, &Error{code: code}) +} + +// Code returns the stable application error code +func (e *Error) Code() Code { + if e == nil { + return "" + } + + return e.code +} + +// HTTPStatus returns the HTTP status associated with the error +func (e *Error) HTTPStatus() int { + if e == nil { + return http.StatusInternalServerError + } + + return e.status +} + +// ClientMessage returns the message that is safe to include in an HTTP response +func (e *Error) ClientMessage() string { + if e == nil { + return "" + } + + return e.message +} + +// Details returns a copy of the additional client-safe details +func (e *Error) Details() map[string]string { + if e == nil || len(e.details) == 0 { + return nil + } + + details := make(map[string]string, len(e.details)) + maps.Copy(details, e.details) + + return details +} + +// Fields returns a copy of the structured validation details +func (e *Error) Fields() []FieldError { + if e == nil || len(e.fields) == 0 { + return nil + } + + return append([]FieldError(nil), e.fields...) +} + +// RetryAfter returns the duration a client should wait before retrying +func (e *Error) RetryAfter() time.Duration { + if e == nil { + return 0 + } + + return e.retryAfter +} + +// WithFields attaches structured validation details without exposing the cause +func (e *Error) WithFields(fields []FieldError) *Error { + if e == nil { + return nil + } + + e.fields = append([]FieldError(nil), fields...) + return e +} + +// WithDetail attaches one client-safe string detail +func (e *Error) WithDetail(key, value string) *Error { + if e == nil { + return nil + } + + if e.details == nil { + e.details = make(map[string]string) + } + e.details[key] = value + return e +} + +// WithRetryAfter attaches a retry delay to the error +func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error { + if e == nil { + return nil + } + + e.retryAfter = retryAfter + return e +} diff --git a/backend/internal/apperror/error_test.go b/backend/internal/apperror/error_test.go new file mode 100644 index 00000000..9f6e27fd --- /dev/null +++ b/backend/internal/apperror/error_test.go @@ -0,0 +1,45 @@ +package apperror + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWrapPreservesCauseWithoutChangingClientMessage(t *testing.T) { + cause := errors.New("database connection details") + err := Wrap(cause, CodeNotFound, http.StatusNotFound, "User not found") + + require.ErrorIs(t, err, cause) + require.Equal(t, "User not found", err.ClientMessage()) + require.Contains(t, err.Error(), "database connection details") +} + +func TestErrorsMatchByCode(t *testing.T) { + err := Wrap(errors.New("database connection details"), CodeAlreadyInUse, http.StatusBadRequest, "email is already in use") + wrapped := Wrap(err, CodeInternal, http.StatusInternalServerError, "request failed") + + require.True(t, IsCode(err, CodeAlreadyInUse)) + require.True(t, IsCode(wrapped, CodeAlreadyInUse)) + require.ErrorIs(t, err, New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use")) + require.NotErrorIs(t, err, errors.Join(New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use"))) + require.False(t, IsCode(wrapped, CodeNotFound)) +} + +func TestErrorCopiesFields(t *testing.T) { + fields := []FieldError{{Field: "email", Code: "required", Message: "is required"}} + err := New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields) + fields[0].Message = "changed" + + require.Equal(t, "is required", err.Fields()[0].Message) +} + +func TestErrorCopiesDetails(t *testing.T) { + err := New(CodeAlreadyInUse, http.StatusBadRequest, "Already in use").WithDetail("property", "email") + details := err.Details() + details["property"] = "password" + + require.Equal(t, map[string]string{"property": "email"}, err.Details()) +} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index efe6e091..8544ed74 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -20,6 +20,7 @@ import ( "github.com/italypaleale/francis/builtin/ratelimit" "github.com/italypaleale/go-kit/servicerunner" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + "go.opentelemetry.io/otel/trace" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/frontend" @@ -404,6 +405,12 @@ func initLogger(r *gin.Engine) { sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger { return slog.Default() }), + sloggin.WithClientErrorLevel(slog.LevelInfo), + sloggin.WithSpecificLogLevelByStatusCode(map[int]slog.Level{ + http.StatusTooManyRequests: slog.LevelWarn, + }), + sloggin.WithContext(enrichRequestLog), + // Skip logging for certain paths to reduce noise in the logs sloggin.WithSkipper(func(c *gin.Context) bool { for _, prefix := range loggerSkipPathsPrefix { if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) { @@ -415,6 +422,33 @@ func initLogger(r *gin.Engine) { )) } +// enrichRequestLog enriches the slog.Record with additional attributes from the request context, such as request ID, error code, and trace/span IDs. +func enrichRequestLog(c *gin.Context, record *slog.Record) *slog.Record { + enriched := slog.NewRecord(record.Time, record.Level, "HTTP request completed", record.PC) + // Add request ID if present in the context + if requestID := middleware.RequestID(c); requestID != "" { + enriched.AddAttrs(slog.String("request_id", requestID)) + } + // Add error code if present in the context + if errorCode := middleware.RequestErrorCode(c); errorCode != "" { + enriched.AddAttrs(slog.String("error_code", string(errorCode))) + } + + // Add trace and span IDs if present in the context + if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() { + enriched.AddAttrs( + slog.String("trace_id", spanContext.TraceID().String()), + slog.String("span_id", spanContext.SpanID().String()), + ) + } + record.Attrs(func(attr slog.Attr) bool { + enriched.AddAttrs(attr) + return true + }) + + return &enriched +} + // tlsCertProvider holds certificates that can be dynamically reloaded type tlsCertProvider struct { certMutex sync.RWMutex diff --git a/backend/internal/bootstrap/router_bootstrap_test.go b/backend/internal/bootstrap/router_bootstrap_test.go new file mode 100644 index 00000000..7561aaf9 --- /dev/null +++ b/backend/internal/bootstrap/router_bootstrap_test.go @@ -0,0 +1,80 @@ +package bootstrap + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/middleware" + "github.com/stretchr/testify/require" +) + +func TestRequestLoggerUsesStructuredErrorMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + var output bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil))) + t.Cleanup(func() { + slog.SetDefault(previousLogger) + }) + + router := gin.New() + initLogger(router) + router.Use(middleware.NewErrorHandlerMiddleware().Add()) + router.GET("/api/users/me", func(c *gin.Context) { + _ = c.Error(apperror.NotSignedIn()) + c.Abort() + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users/me", nil) + router.ServeHTTP(recorder, request) + + var body struct { + RequestID string `json:"request_id"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.NotEmpty(t, body.RequestID) + + logLine := output.String() + require.Contains(t, logLine, "level=INFO") + require.Contains(t, logLine, `msg="HTTP request completed"`) + require.Contains(t, logLine, "request_id="+body.RequestID) + require.Contains(t, logLine, "error_code=not_signed_in") + require.Contains(t, logLine, "status=401") + require.NotContains(t, logLine, "Request with errors") + require.NotContains(t, logLine, "Error #01") +} + +func TestRequestLoggerKeepsRateLimitsAtWarningLevel(t *testing.T) { + gin.SetMode(gin.TestMode) + + var output bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil))) + t.Cleanup(func() { + slog.SetDefault(previousLogger) + }) + + router := gin.New() + initLogger(router) + router.Use(middleware.NewErrorHandlerMiddleware().Add()) + router.GET("/api/limited", func(c *gin.Context) { + _ = c.Error(apperror.TooManyRequests()) + c.Abort() + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/limited", nil) + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusTooManyRequests, recorder.Code) + require.Contains(t, output.String(), "level=WARN") + require.Contains(t, output.String(), "error_code=rate_limited") +} diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index c13cc6d5..30f4f487 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -328,7 +328,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) { require.NoError(t, err) dbConnFile := tempDir + "/db_connection.txt" - dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential + dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600) require.NoError(t, err) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go deleted file mode 100644 index 07f49561..00000000 --- a/backend/internal/common/errors.go +++ /dev/null @@ -1,309 +0,0 @@ -package common - -import ( - "errors" - "fmt" - "net/http" -) - -type AppError interface { - error - - HttpStatusCode() int -} - -type AppErrorDescription interface { - AppError - - Description() string -} - -// Custom error types for various conditions - -type AlreadyInUseError struct { - Property string -} - -func (e AlreadyInUseError) Error() string { - return e.Property + " is already in use" -} -func (e AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest } - -func (e AlreadyInUseError) Is(target error) bool { - // Ignore the field property when checking if an error is of the type AlreadyInUseError - x := &AlreadyInUseError{} - return errors.As(target, &x) -} - -type SetupNotAvailableError struct{} - -func (e SetupNotAvailableError) Error() string { return "not found" } -func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound } - -type TokenInvalidOrExpiredError struct{} - -func (e TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" } -func (e TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized } - -type DeviceCodeInvalid struct{} - -func (e DeviceCodeInvalid) Error() string { - return "one time access code must be used on the device it was generated for" -} -func (e DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized } - -type TokenInvalidError struct{} - -func (e TokenInvalidError) Error() string { return "Token is invalid" } -func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } - -type OidcMissingAuthorizationError struct{} - -func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" } -func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden } - -type OidcInvalidCallbackURLError struct{} - -func (e OidcInvalidCallbackURLError) Error() string { - return "invalid callback URL, it might be necessary for an admin to fix this" -} -func (e OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest } - -type InvalidCIMDURLPatternError struct { - Pattern string -} - -func (e InvalidCIMDURLPatternError) Error() string { - return "invalid metadata document URL pattern: " + e.Pattern -} -func (e InvalidCIMDURLPatternError) HttpStatusCode() int { return http.StatusBadRequest } - -type FileTypeNotSupportedError struct{} - -func (e FileTypeNotSupportedError) Error() string { return "file type not supported" } -func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest } - -type FileTooLargeError struct { - MaxSize string -} - -func (e FileTooLargeError) Error() string { - return fmt.Sprintf("The file can't be larger than %s", e.MaxSize) -} -func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge } - -type NotSignedInError struct{} - -func (e NotSignedInError) Error() string { return "You are not signed in" } -func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized } - -type MissingPermissionError struct{} - -func (e MissingPermissionError) Error() string { - return "You don't have permission to perform this action" -} -func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden } - -type TooManyRequestsError struct{} - -func (e TooManyRequestsError) Error() string { return "Too many requests" } -func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests } - -type UserIdNotProvidedError struct{} - -func (e UserIdNotProvidedError) Error() string { return "User id not provided" } -func (e UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest } - -type UserNotFoundError struct{} - -func (e UserNotFoundError) Error() string { return "User not found" } -func (e UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound } - -type WrongFileTypeError struct { - ExpectedFileType string -} - -func (e WrongFileTypeError) Error() string { - return fmt.Sprintf("File must be of type %s", e.ExpectedFileType) -} -func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest } - -type MissingSessionIdError struct{} - -func (e MissingSessionIdError) Error() string { return "Missing session id" } -func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest } - -type InvalidWebauthnSessionError struct{} - -func (e InvalidWebauthnSessionError) Error() string { - return "WebAuthn session is invalid or has expired" -} -func (e InvalidWebauthnSessionError) HttpStatusCode() int { return http.StatusBadRequest } - -type ReservedClaimError struct { - Key string -} - -func (e ReservedClaimError) Error() string { - return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key) -} -func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest } - -type DuplicateClaimError struct { - Key string -} - -func (e DuplicateClaimError) Error() string { - return fmt.Sprintf("Claim %s is already defined", e.Key) -} -func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest } - -type LdapUserUpdateError struct{} - -func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" } -func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden } - -type LdapUserGroupUpdateError struct{} - -func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" } -func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden } - -type OidcAccessDeniedError struct{} - -func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" } -func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden } - -type OidcClientIdNotMatchingError struct{} - -func (e OidcClientIdNotMatchingError) Error() string { - return "Client id in request doesn't match client id in token" -} -func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest } - -type UiConfigDisabledError struct{} - -func (e UiConfigDisabledError) Error() string { - return "The configuration can't be changed since the UI configuration is disabled" -} -func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden } - -type InvalidUUIDError struct{} - -func (e InvalidUUIDError) Error() string { return "Invalid UUID" } -func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest } - -type OneTimeAccessDisabledError struct{} - -func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" } -func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest } - -type DeviceLoginRequestInvalidOrExpiredError struct{} - -func (e DeviceLoginRequestInvalidOrExpiredError) Error() string { - return "Device login request is invalid or expired" -} -func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int { - return http.StatusUnauthorized -} - -type DeviceLoginDeniedError struct{} - -func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" } -func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden } - -type InvalidAPIKeyError struct{} - -func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" } -func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized } - -type NoAPIKeyProvidedError struct{} - -func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" } -func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized } - -type APIKeyNotFoundError struct{} - -func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" } -func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized } - -type APIKeyNotExpiredError struct{} - -func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" } -func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type APIKeyExpirationDateError struct{} - -func (e APIKeyExpirationDateError) Error() string { - return "API Key expiration time must be in the future" -} -func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest } - -type APIKeyAuthNotAllowedError struct{} - -func (e APIKeyAuthNotAllowedError) Error() string { - return "API key authentication is not allowed for this endpoint" -} -func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden } - -type UserDisabledError struct{} - -func (e UserDisabledError) Error() string { return "User account is disabled" } -func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden } - -type ValidationError struct{ Message string } - -func (e ValidationError) Error() string { return e.Message } - -func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcDeviceCodeExpiredError struct{} - -func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" } -func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type OidcInvalidDeviceCodeError struct{} - -func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" } -func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest } - -type ReauthenticationRequiredError struct{} - -func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" } -func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized } - -type OpenSignupDisabledError struct{} - -func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" } - -func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden } - -type ClientIdAlreadyExistsError struct{} - -func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" } - -func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest } - -type UserEmailNotSetError struct{} - -func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" } - -func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest } - -type ImageNotFoundError struct{} - -func (e ImageNotFoundError) Error() string { return "Image not found" } - -func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound } - -type OidcPARRequiredError struct{} - -func (e OidcPARRequiredError) Error() string { - return "this client requires pushed authorization requests" -} -func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest } - -type InvalidEmailVerificationTokenError struct{} - -func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" } - -func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest } diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index cbb8eaa4..1ca995e5 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -9,6 +9,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/tracing" @@ -35,12 +36,12 @@ func NewAppConfigController( emailSender: emailSender, ldapService: ldapService, } - group.GET("/application-configuration", acc.listAppConfigHandler) - group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler) - group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler) + group.GET("/application-configuration", httpserver.Handle(acc.listAppConfigHandler)) + group.GET("/application-configuration/all", authMiddleware.Add(), httpserver.Handle(acc.listAllAppConfigHandler)) + group.PUT("/application-configuration", authMiddleware.Add(), httpserver.Handle(acc.updateAppConfigHandler)) - group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler) - group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler) + group.POST("/application-configuration/test-email", authMiddleware.Add(), httpserver.Handle(acc.testEmailHandler)) + group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), httpserver.Handle(acc.syncLdapHandler)) } type AppConfigController struct { @@ -57,18 +58,16 @@ type AppConfigController struct { // @Produce json // @Success 200 {array} dto.PublicAppConfigVariableDto // @Router /api/application-configuration [get] -func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { +func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error { dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } configuration := dbConfig.ToAppConfigVariableSlice(false, true) var configVariablesDto []dto.PublicAppConfigVariableDto if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { - _ = c.Error(err) - return + return err } // Manually add uiConfigDisabled which isn't in the database but defined with an environment variable @@ -85,6 +84,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { }) c.JSON(http.StatusOK, configVariablesDto) + return nil } // listAllAppConfigHandler godoc @@ -95,21 +95,20 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { // @Produce json // @Success 200 {array} dto.AppConfigVariableDto // @Router /api/application-configuration/all [get] -func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { +func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) error { dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } configuration := dbConfig.ToAppConfigVariableSlice(true, true) var configVariablesDto []dto.AppConfigVariableDto if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, configVariablesDto) + return nil } // updateAppConfigHandler godoc @@ -121,26 +120,24 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { // @Param body body dto.AppConfigUpdateDto true "Application Configuration" // @Success 200 {array} dto.AppConfigVariableDto // @Router /api/application-configuration [put] -func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { +func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) error { var input dto.AppConfigUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input) if err != nil { - _ = c.Error(err) - return + return err } var configVariablesDto []dto.AppConfigVariableDto if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, configVariablesDto) + return nil } // syncLdapHandler godoc @@ -149,20 +146,19 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { // @Tags Application Configuration // @Success 204 "No Content" // @Router /api/application-configuration/sync-ldap [post] -func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { +func (acc *AppConfigController) syncLdapHandler(c *gin.Context) error { dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // testEmailHandler godoc @@ -171,20 +167,19 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { // @Tags Application Configuration // @Success 204 "No Content" // @Router /api/application-configuration/test-email [post] -func (acc *AppConfigController) testEmailHandler(c *gin.Context) { +func (acc *AppConfigController) testEmailHandler(c *gin.Context) error { dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } userID := c.GetString("userID") err = acc.emailSender.SendTestEmail(c.Request.Context(), dbConfig, userID) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index 2230b6f0..fd759953 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -9,7 +9,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -24,20 +25,20 @@ func NewAppImagesController( appImagesService: appImagesService, } - group.GET("/application-images/logo", controller.getLogoHandler) - group.GET("/application-images/email", controller.getEmailLogoHandler) - group.GET("/application-images/background", controller.getBackgroundImageHandler) - group.GET("/application-images/favicon", controller.getFaviconHandler) - group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture) + group.GET("/application-images/logo", httpserver.Handle(controller.getLogoHandler)) + group.GET("/application-images/email", httpserver.Handle(controller.getEmailLogoHandler)) + group.GET("/application-images/background", httpserver.Handle(controller.getBackgroundImageHandler)) + group.GET("/application-images/favicon", httpserver.Handle(controller.getFaviconHandler)) + group.GET("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.getDefaultProfilePicture)) - group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler) - group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler) - group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler) - group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) - group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture) + group.PUT("/application-images/logo", authMiddleware.Add(), httpserver.Handle(controller.updateLogoHandler)) + group.PUT("/application-images/email", authMiddleware.Add(), httpserver.Handle(controller.updateEmailLogoHandler)) + group.PUT("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.updateBackgroundImageHandler)) + group.PUT("/application-images/favicon", authMiddleware.Add(), httpserver.Handle(controller.updateFaviconHandler)) + group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.updateDefaultProfilePicture)) - group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler) - group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture) + group.DELETE("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.deleteBackgroundImageHandler)) + group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.deleteDefaultProfilePicture)) } type AppImagesController struct { @@ -54,14 +55,14 @@ type AppImagesController struct { // @Produce image/svg+xml // @Success 200 {file} binary "Logo image" // @Router /api/application-images/logo [get] -func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { +func (c *AppImagesController) getLogoHandler(ctx *gin.Context) error { lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) imageName := "logoLight" if !lightLogo { imageName = "logoDark" } - c.getImage(ctx, imageName) + return c.getImage(ctx, imageName) } // getEmailLogoHandler godoc @@ -72,8 +73,8 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { // @Produce image/jpeg // @Success 200 {file} binary "Email logo image" // @Router /api/application-images/email [get] -func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) { - c.getImage(ctx, "logoEmail") +func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) error { + return c.getImage(ctx, "logoEmail") } // getBackgroundImageHandler godoc @@ -84,8 +85,8 @@ func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) { // @Produce image/jpeg // @Success 200 {file} binary "Background image" // @Router /api/application-images/background [get] -func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) { - c.getImage(ctx, "background") +func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) error { + return c.getImage(ctx, "background") } // getFaviconHandler godoc @@ -95,8 +96,8 @@ func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) { // @Produce image/x-icon // @Success 200 {file} binary "Favicon image" // @Router /api/application-images/favicon [get] -func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) { - c.getImage(ctx, "favicon") +func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) error { + return c.getImage(ctx, "favicon") } // getDefaultProfilePicture godoc @@ -107,8 +108,8 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) { // @Produce image/jpeg // @Success 200 {file} binary "Default profile picture image" // @Router /api/application-images/default-profile-picture [get] -func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) { - c.getImage(ctx, "default-profile-picture") +func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) error { + return c.getImage(ctx, "default-profile-picture") } // updateLogoHandler godoc @@ -120,11 +121,10 @@ func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) { // @Param file formData file true "Logo image file" // @Success 204 "No Content" // @Router /api/application-images/logo [put] -func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { - file, err := ctx.FormFile("file") +func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error { + file, err := httpserver.FormFile(ctx, "file") if err != nil { - _ = ctx.Error(err) - return + return err } lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) @@ -134,11 +134,11 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { } if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } // updateEmailLogoHandler godoc @@ -149,27 +149,25 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { // @Param file formData file true "Email logo image file" // @Success 204 "No Content" // @Router /api/application-images/email [put] -func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) { - file, err := ctx.FormFile("file") +func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) error { + file, err := httpserver.FormFile(ctx, "file") if err != nil { - _ = ctx.Error(err) - return + return err } fileType := utils.GetFileExtension(file.Filename) mimeType := utils.GetImageMimeType(fileType) if mimeType != "image/png" && mimeType != "image/jpeg" { - _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}) - return + return apperror.UnsupportedFileType("PNG or JPEG") } if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } // updateBackgroundImageHandler godoc @@ -180,19 +178,18 @@ func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) { // @Param file formData file true "Background image file" // @Success 204 "No Content" // @Router /api/application-images/background [put] -func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) { - file, err := ctx.FormFile("file") +func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) error { + file, err := httpserver.FormFile(ctx, "file") if err != nil { - _ = ctx.Error(err) - return + return err } if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } // deleteBackgroundImageHandler godoc @@ -201,13 +198,13 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) { // @Tags Application Images // @Success 204 "No Content" // @Router /api/application-images/background [delete] -func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) { +func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) error { if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } // updateFaviconHandler godoc @@ -218,39 +215,37 @@ func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) { // @Param file formData file true "Favicon file (.svg/.png/.ico)" // @Success 204 "No Content" // @Router /api/application-images/favicon [put] -func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) { - file, err := ctx.FormFile("file") +func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) error { + file, err := httpserver.FormFile(ctx, "file") if err != nil { - _ = ctx.Error(err) - return + return err } fileType := utils.GetFileExtension(file.Filename) mimeType := utils.GetImageMimeType(strings.ToLower(fileType)) if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) { - _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"}) - return + return apperror.UnsupportedFileType("SVG, PNG, or ICO") } if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } -func (c *AppImagesController) getImage(ctx *gin.Context, name string) { +func (c *AppImagesController) getImage(ctx *gin.Context, name string) error { reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name) if err != nil { - _ = ctx.Error(err) - return + return err } defer reader.Close() ctx.Header("Content-Type", mimeType) utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour) ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil) + return nil } // updateDefaultProfilePicture godoc @@ -261,19 +256,18 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) { // @Param file formData file true "Profile picture image file" // @Success 204 "No Content" // @Router /api/application-images/default-profile-picture [put] -func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) { - file, err := ctx.FormFile("file") +func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) error { + file, err := httpserver.FormFile(ctx, "file") if err != nil { - _ = ctx.Error(err) - return + return err } if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } // deleteDefaultProfilePicture godoc @@ -282,11 +276,11 @@ func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) { // @Tags Application Images // @Success 204 "No Content" // @Router /api/application-images/default-profile-picture [delete] -func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) { +func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) error { if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go index 3eccac4b..b483e174 100644 --- a/backend/internal/controller/audit_log_controller.go +++ b/backend/internal/controller/audit_log_controller.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -20,10 +21,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi auditLogService: auditLogService, } - group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler) - group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler) - group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler) - group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler) + group.GET("/audit-logs/all", authMiddleware.Add(), httpserver.Handle(alc.listAllAuditLogsHandler)) + group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(alc.listAuditLogsForUserHandler)) + group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), httpserver.Handle(alc.listClientNamesHandler)) + group.GET("/audit-logs/filters/users", authMiddleware.Add(), httpserver.Handle(alc.listUserNamesWithIdsHandler)) } type AuditLogController struct { @@ -40,7 +41,7 @@ type AuditLogController struct { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.AuditLogDto] // @Router /api/audit-logs [get] -func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { +func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) userID := c.GetString("userID") @@ -48,16 +49,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { // Fetch audit logs for the user logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } // Map the audit logs to DTOs var logsDtos []dto.AuditLogDto err = dto.MapStructList(logs, &logsDtos) if err != nil { - _ = c.Error(err) - return + return err } // Add device information to the logs @@ -71,6 +70,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { Data: logsDtos, Pagination: pagination, }) + return nil } // listAllAuditLogsHandler godoc @@ -83,20 +83,18 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.AuditLogDto] // @Router /api/audit-logs/all [get] -func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) { +func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } var logsDtos []dto.AuditLogDto err = dto.MapStructList(logs, &logsDtos) if err != nil { - _ = c.Error(err) - return + return err } for i, logsDto := range logsDtos { @@ -110,6 +108,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) { Data: logsDtos, Pagination: pagination, }) + return nil } // listClientNamesHandler godoc @@ -118,14 +117,14 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) { // @Tags Audit Logs // @Success 200 {array} string "List of client names" // @Router /api/audit-logs/filters/client-names [get] -func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) { +func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) error { names, err := alc.auditLogService.ListClientNames(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, names) + return nil } // listUserNamesWithIdsHandler godoc @@ -134,12 +133,12 @@ func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) { // @Tags Audit Logs // @Success 200 {object} map[string]string "Map of user IDs to usernames" // @Router /api/audit-logs/filters/users [get] -func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) { +func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) error { users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, users) + return nil } diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go index 8b4731c3..93858520 100644 --- a/backend/internal/controller/custom_claim_controller.go +++ b/backend/internal/controller/custom_claim_controller.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -19,9 +20,9 @@ func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware customClaimsGroup := group.Group("/custom-claims") customClaimsGroup.Use(authMiddleware.Add()) { - customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler) - customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler) - customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler) + customClaimsGroup.GET("/suggestions", httpserver.Handle(wkc.getSuggestionsHandler)) + customClaimsGroup.PUT("/user/:userId", httpserver.Handle(wkc.UpdateCustomClaimsForUserHandler)) + customClaimsGroup.PUT("/user-group/:userGroupId", httpserver.Handle(wkc.UpdateCustomClaimsForUserGroupHandler)) } } @@ -36,14 +37,14 @@ type CustomClaimController struct { // @Produce json // @Success 200 {array} string "List of suggested custom claim names" // @Router /api/custom-claims/suggestions [get] -func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { +func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) error { claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, claims) + return nil } // UpdateCustomClaimsForUserHandler godoc @@ -56,28 +57,26 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { // @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user" // @Success 200 {array} dto.CustomClaimDto "Updated custom claims" // @Router /api/custom-claims/user/{userId} [put] -func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) { +func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) error { var input []dto.CustomClaimCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } userId := c.Param("userId") claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input) if err != nil { - _ = c.Error(err) - return + return err } var customClaimsDto []dto.CustomClaimDto if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, customClaimsDto) + return nil } // UpdateCustomClaimsForUserGroupHandler godoc @@ -90,26 +89,24 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex // @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group" // @Success 200 {array} dto.CustomClaimDto "Updated custom claims" // @Router /api/custom-claims/user-group/{userGroupId} [put] -func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { +func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) error { var input []dto.CustomClaimCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } userGroupId := c.Param("userGroupId") claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input) if err != nil { - _ = c.Error(err) - return + return err } var customClaimsDto []dto.CustomClaimDto if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, customClaimsDto) + return nil } diff --git a/backend/internal/controller/e2etest_controller.go b/backend/internal/controller/e2etest_controller.go index 2c6abc73..37a2687d 100644 --- a/backend/internal/controller/e2etest_controller.go +++ b/backend/internal/controller/e2etest_controller.go @@ -7,25 +7,26 @@ import ( "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/service" ) func NewTestController(group *gin.RouterGroup, testService *service.TestService) { testController := &TestController{TestService: testService} - group.POST("/test/reset", testController.resetAndSeedHandler) - group.POST("/test/accesstoken", testController.signAccessToken) - group.POST("/test/refreshtoken", testController.signRefreshToken) + group.POST("/test/reset", httpserver.Handle(testController.resetAndSeedHandler)) + group.POST("/test/accesstoken", httpserver.Handle(testController.signAccessToken)) + group.POST("/test/refreshtoken", httpserver.Handle(testController.signRefreshToken)) - group.GET("/externalidp/jwks.json", testController.externalIdPJWKS) - group.POST("/externalidp/sign", testController.externalIdPSignToken) + group.GET("/externalidp/jwks.json", httpserver.Handle(testController.externalIdPJWKS)) + group.POST("/externalidp/sign", httpserver.Handle(testController.externalIdPSignToken)) } type TestController struct { TestService *service.TestService } -func (tc *TestController) resetAndSeedHandler(c *gin.Context) { +func (tc *TestController) resetAndSeedHandler(c *gin.Context) error { var baseURL string if c.Request.TLS != nil { baseURL = "https://" + c.Request.Host @@ -37,111 +38,103 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) { skipSeed := c.Query("skip-seed") == "true" if err := tc.TestService.ResetDatabase(); err != nil { - _ = c.Error(err) - return + return err } if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil { - _ = c.Error(err) - return + return err } if !skipSeed { if err := tc.TestService.SeedDatabase(baseURL); err != nil { - _ = c.Error(err) - return + return err } } if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil { - _ = c.Error(err) - return + return err } if !skipLdap { if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil { - _ = c.Error(err) - return + return err } if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil { - _ = c.Error(err) - return + return err } } c.Status(http.StatusNoContent) + return nil } -func (tc *TestController) externalIdPJWKS(c *gin.Context) { +func (tc *TestController) externalIdPJWKS(c *gin.Context) error { jwks, err := tc.TestService.GetExternalIdPJWKS() if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, jwks) + return nil } -func (tc *TestController) externalIdPSignToken(c *gin.Context) { +func (tc *TestController) externalIdPSignToken(c *gin.Context) error { var input struct { Aud string `json:"aud"` Iss string `json:"iss"` Sub string `json:"sub"` } - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud) if err != nil { - _ = c.Error(err) - return + return err } c.Writer.WriteString(token) + return nil } -func (tc *TestController) signAccessToken(c *gin.Context) { +func (tc *TestController) signAccessToken(c *gin.Context) error { var input struct { UserID string `json:"user"` ClientID string `json:"client"` Expired bool `json:"expired"` } - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired) if err != nil { - _ = c.Error(err) - return + return err } c.Writer.WriteString(token) + return nil } -func (tc *TestController) signRefreshToken(c *gin.Context) { +func (tc *TestController) signRefreshToken(c *gin.Context) error { var input struct { UserID string `json:"user"` ClientID string `json:"client"` RefreshToken string `json:"rt"` } - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken) if err != nil { - _ = c.Error(err) - return + return err } c.Writer.WriteString(token) + return nil } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index afbaa99f..94d4afce 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -1,8 +1,6 @@ package controller import ( - "errors" - "io" "net/http" "strconv" "strings" @@ -10,8 +8,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -26,31 +25,31 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi oidcService: oidcService, } - group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler) - group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler) - group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler) - group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler) - group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler) - group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), oc.refreshClientMetadataHandler) - group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler) + group.GET("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.listClientsHandler)) + group.POST("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.createClientHandler)) + group.GET("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.getClientHandler)) + group.GET("/oidc/clients/:id/meta", httpserver.Handle(oc.getClientMetaDataHandler)) + group.PUT("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.updateClientHandler)) + group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), httpserver.Handle(oc.refreshClientMetadataHandler)) + group.DELETE("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.deleteClientHandler)) - group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler) - group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler) + group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), httpserver.Handle(oc.updateAllowedUserGroupsHandler)) + group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), httpserver.Handle(oc.createClientSecretHandler)) - group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler) - group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler) - group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) + group.GET("/oidc/clients/:id/logo", httpserver.Handle(oc.getClientLogoHandler)) + group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), httpserver.Handle(oc.deleteClientLogoHandler)) + group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), httpserver.Handle(oc.updateClientLogoHandler)) - group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler) + group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), httpserver.Handle(oc.getClientPreviewHandler)) - group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) - group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) + group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAuthorizedClientsHandler)) + group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), httpserver.Handle(oc.listAuthorizedClientsHandler)) - group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler) + group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.revokeOwnClientAuthorizationHandler)) - group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler) + group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAccessibleClientsHandler)) - group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler) + group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), httpserver.Handle(oc.getClientScimServiceProviderHandler)) } @@ -66,22 +65,21 @@ type OidcController struct { // @Param id path string true "Client ID" // @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata" // @Router /api/oidc/clients/{id}/meta [get] -func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) { +func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) error { clientId := c.Param("id") client, err := oc.oidcService.GetClient(c.Request.Context(), clientId) if err != nil { - _ = c.Error(err) - return + return err } clientDto := dto.OidcClientMetaDataDto{} - err = dto.MapStruct(client, &clientDto) - if err == nil { - c.JSON(http.StatusOK, clientDto) - return + if err := dto.MapStruct(client, &clientDto); err != nil { + return err } - _ = c.Error(err) + clientDto.HasDarkLogo = client.HasDarkLogo() + c.JSON(http.StatusOK, clientDto) + return nil } // getClientHandler godoc @@ -92,22 +90,21 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) { // @Param id path string true "Client ID" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information" // @Router /api/oidc/clients/{id} [get] -func (oc *OidcController) getClientHandler(c *gin.Context) { +func (oc *OidcController) getClientHandler(c *gin.Context) error { clientId := c.Param("id") client, err := oc.oidcService.GetClient(c.Request.Context(), clientId) if err != nil { - _ = c.Error(err) - return + return err } clientDto := dto.OidcClientWithAllowedUserGroupsDto{} err = dto.MapStruct(client, &clientDto) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, clientDto) + return nil } // listClientsHandler godoc @@ -121,14 +118,13 @@ func (oc *OidcController) getClientHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto] // @Router /api/oidc/clients [get] -func (oc *OidcController) listClientsHandler(c *gin.Context) { +func (oc *OidcController) listClientsHandler(c *gin.Context) error { searchTerm := c.Query("search") listRequestOptions := utils.ParseListRequestOptions(c) clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } // Map the user groups to DTOs @@ -136,14 +132,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) { for i, client := range clients { var clientDto dto.OidcClientWithAllowedGroupsCountDto if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return + return err } + clientDto.HasDarkLogo = client.HasDarkLogo() clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID) if err != nil { - _ = c.Error(err) - return + return err } clientsDto[i] = clientDto } @@ -152,6 +147,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) { Data: clientsDto, Pagination: pagination, }) + return nil } // createClientHandler godoc @@ -163,26 +159,24 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) { // @Param client body dto.OidcClientCreateDto true "Client information" // @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client" // @Router /api/oidc/clients [post] -func (oc *OidcController) createClientHandler(c *gin.Context) { +func (oc *OidcController) createClientHandler(c *gin.Context) error { var input dto.OidcClientCreateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID")) if err != nil { - _ = c.Error(err) - return + return err } var clientDto dto.OidcClientWithAllowedUserGroupsDto if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, clientDto) + return nil } // deleteClientHandler godoc @@ -192,14 +186,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) { // @Param id path string true "Client ID" // @Success 204 "No Content" // @Router /api/oidc/clients/{id} [delete] -func (oc *OidcController) deleteClientHandler(c *gin.Context) { +func (oc *OidcController) deleteClientHandler(c *gin.Context) error { err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id")) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updateClientHandler godoc @@ -212,26 +206,24 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) { // @Param client body dto.OidcClientUpdateDto true "Client information" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client" // @Router /api/oidc/clients/{id} [put] -func (oc *OidcController) updateClientHandler(c *gin.Context) { +func (oc *OidcController) updateClientHandler(c *gin.Context) error { var input dto.OidcClientUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } var clientDto dto.OidcClientWithAllowedUserGroupsDto if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, clientDto) + return nil } // refreshClientMetadataHandler godoc @@ -242,20 +234,20 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) { // @Param id path string true "Client ID" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Refreshed client" // @Router /api/oidc/clients/{id}/refresh [post] -func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) { +func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) error { client, err := oc.oidcService.RefreshClientMetadata(c.Request.Context(), c.Param("id")) if err != nil { - _ = c.Error(err) - return + return err } var clientDto dto.OidcClientWithAllowedUserGroupsDto if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return + return err } + clientDto.HasDarkLogo = client.HasDarkLogo() c.JSON(http.StatusOK, clientDto) + return nil } // createClientSecretHandler godoc @@ -268,20 +260,19 @@ func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) { // @Param payload body dto.OidcClientSecretDto false "Client secret" // @Success 200 {object} object "{ \"secret\": \"string\" }" // @Router /api/oidc/clients/{id}/secret [post] -func (oc *OidcController) createClientSecretHandler(c *gin.Context) { +func (oc *OidcController) createClientSecretHandler(c *gin.Context) error { var input dto.OidcClientSecretDto - if err := c.ShouldBindJSON(&input); err != nil && !errors.Is(err, io.EOF) { - _ = c.Error(err) - return + if err := httpserver.BindOptionalJSON(c, &input); err != nil { + return err } secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, gin.H{"secret": secret}) + return nil } // getClientLogoHandler godoc @@ -295,13 +286,12 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) { // @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 200 {file} binary "Logo image" // @Router /api/oidc/clients/{id}/logo [get] -func (oc *OidcController) getClientLogoHandler(c *gin.Context) { +func (oc *OidcController) getClientLogoHandler(c *gin.Context) error { lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo) if err != nil { - _ = c.Error(err) - return + return err } defer reader.Close() @@ -309,6 +299,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) { c.Header("Content-Type", mimeType) c.DataFromReader(http.StatusOK, size, mimeType, reader, nil) + return nil } // updateClientLogoHandler godoc @@ -321,22 +312,21 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) { // @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 204 "No Content" // @Router /api/oidc/clients/{id}/logo [post] -func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { - file, err := c.FormFile("file") +func (oc *OidcController) updateClientLogoHandler(c *gin.Context) error { + file, err := httpserver.FormFile(c, "file") if err != nil { - _ = c.Error(err) - return + return err } lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // deleteClientLogoHandler godoc @@ -347,7 +337,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { // @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 204 "No Content" // @Router /api/oidc/clients/{id}/logo [delete] -func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { +func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) error { var err error lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) @@ -358,11 +348,11 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { } if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updateAllowedUserGroupsHandler godoc @@ -375,27 +365,25 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { // @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs" // @Success 200 {object} dto.OidcClientDto "Updated client" // @Router /api/oidc/clients/{id}/allowed-user-groups [put] -func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { +func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error { var input dto.OidcUpdateAllowedUserGroupsDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } var oidcClientDto dto.OidcClientDto if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil { - _ = c.Error(err) - return + return err } oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo() c.JSON(http.StatusOK, oidcClientDto) + return nil } // listOwnAuthorizedClientsHandler godoc @@ -408,9 +396,9 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Router /api/oidc/users/me/authorized-clients [get] -func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) { +func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error { userID := c.GetString("userID") - oc.listAuthorizedClients(c, userID) + return oc.listAuthorizedClients(c, userID) } // listAuthorizedClientsHandler godoc @@ -424,31 +412,30 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Router /api/oidc/users/{id}/authorized-clients [get] -func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) { +func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error { userID := c.Param("id") - oc.listAuthorizedClients(c, userID) + return oc.listAuthorizedClients(c, userID) } -func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { +func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) error { listRequestOptions := utils.ParseListRequestOptions(c) authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } // Map the clients to DTOs var authorizedClientsDto []dto.AuthorizedOidcClientDto if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{ Data: authorizedClientsDto, Pagination: pagination, }) + return nil } // revokeOwnClientAuthorizationHandler godoc @@ -458,18 +445,18 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { // @Param clientId path string true "Client ID to revoke authorization for" // @Success 204 "No Content" // @Router /api/oidc/users/me/authorized-clients/{clientId} [delete] -func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) { +func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) error { clientID := c.Param("clientId") userID := c.GetString("userID") err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // listOwnAccessibleClientsHandler godoc @@ -482,21 +469,21 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto] // @Router /api/oidc/users/me/clients [get] -func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) { +func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) userID := c.GetString("userID") clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{ Data: clients, Pagination: pagination, }) + return nil } // getClientPreviewHandler godoc @@ -510,24 +497,21 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) { // @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads" // @Security BearerAuth // @Router /api/oidc/clients/{id}/preview/{userId} [get] -func (oc *OidcController) getClientPreviewHandler(c *gin.Context) { +func (oc *OidcController) getClientPreviewHandler(c *gin.Context) error { clientID := c.Param("id") userID := c.Param("userId") scopes := c.Query("scopes") if clientID == "" { - _ = c.Error(&common.ValidationError{Message: "client ID is required"}) - return + return apperror.MissingField("clientId") } if userID == "" { - _ = c.Error(&common.ValidationError{Message: "user ID is required"}) - return + return apperror.MissingField("userId") } if scopes == "" { - _ = c.Error(&common.ValidationError{Message: "scopes are required"}) - return + return apperror.MissingField("scopes") } preview, err := oc.oidcService.GetClientPreview( @@ -538,11 +522,11 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) { c.GetString("authenticationMethod")) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, preview) + return nil } // getClientScimServiceProviderHandler godoc @@ -553,20 +537,19 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) { // @Param id path string true "Client ID" // @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration" // @Router /api/oidc/clients/{id}/scim-service-provider [get] -func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) { +func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) error { clientID := c.Param("id") provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID) if err != nil { - _ = c.Error(err) - return + return err } var providerDto dto.ScimServiceProviderDTO if err := dto.MapStruct(provider, &providerDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, providerDto) + return nil } diff --git a/backend/internal/controller/scim_controller.go b/backend/internal/controller/scim_controller.go index a9762921..9cda41ee 100644 --- a/backend/internal/controller/scim_controller.go +++ b/backend/internal/controller/scim_controller.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -14,10 +15,10 @@ func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi scimService: scimService, } - group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler) - group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler) - group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler) - group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler) + group.POST("/scim/service-provider", authMiddleware.Add(), httpserver.Handle(ugc.createServiceProviderHandler)) + group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), httpserver.Handle(ugc.syncServiceProviderHandler)) + group.PUT("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.updateServiceProviderHandler)) + group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.deleteServiceProviderHandler)) } type ScimController struct { @@ -31,14 +32,14 @@ type ScimController struct { // @Param id path string true "Service Provider ID" // @Success 200 "OK" // @Router /api/scim/service-provider/{id}/sync [post] -func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) { +func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) error { err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id")) if err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusOK) + return nil } // createServiceProviderHandler godoc @@ -50,26 +51,24 @@ func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) { // @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information" // @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider" // @Router /api/scim/service-provider [post] -func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) { +func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) error { var input dto.ScimServiceProviderCreateDTO - if err := ctx.ShouldBindJSON(&input); err != nil { - _ = ctx.Error(err) - return + if err := httpserver.BindJSON(ctx, &input); err != nil { + return err } provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input) if err != nil { - _ = ctx.Error(err) - return + return err } var providerDTO dto.ScimServiceProviderDTO if err := dto.MapStruct(provider, &providerDTO); err != nil { - _ = ctx.Error(err) - return + return err } ctx.JSON(http.StatusCreated, providerDTO) + return nil } // updateServiceProviderHandler godoc @@ -82,26 +81,24 @@ func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) { // @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information" // @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider" // @Router /api/scim/service-provider/{id} [put] -func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) { +func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) error { var input dto.ScimServiceProviderCreateDTO - if err := ctx.ShouldBindJSON(&input); err != nil { - _ = ctx.Error(err) - return + if err := httpserver.BindJSON(ctx, &input); err != nil { + return err } provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input) if err != nil { - _ = ctx.Error(err) - return + return err } var providerDTO dto.ScimServiceProviderDTO if err := dto.MapStruct(provider, &providerDTO); err != nil { - _ = ctx.Error(err) - return + return err } ctx.JSON(http.StatusOK, providerDTO) + return nil } // deleteServiceProviderHandler godoc @@ -111,12 +108,12 @@ func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) { // @Param id path string true "Service Provider ID" // @Success 204 "No Content" // @Router /api/scim/service-provider/{id} [delete] -func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) { +func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) error { err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id")) if err != nil { - _ = ctx.Error(err) - return + return err } ctx.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index ecb5ccc3..33d12b9f 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -26,26 +27,26 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi webAuthnService: webAuthnService, } - group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) - group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler) - group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler) - group.POST("/users", authMiddleware.Add(), uc.createUserHandler) - group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler) - group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler) - group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler) - group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler) - group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler) - group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler) + group.GET("/users", authMiddleware.Add(), httpserver.Handle(uc.listUsersHandler)) + group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.getCurrentUserHandler)) + group.GET("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.getUserHandler)) + group.POST("/users", authMiddleware.Add(), httpserver.Handle(uc.createUserHandler)) + group.PUT("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.updateUserHandler)) + group.GET("/users/:id/groups", authMiddleware.Add(), httpserver.Handle(uc.getUserGroupsHandler)) + group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), httpserver.Handle(uc.listUserWebauthnCredentialsHandler)) + group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserHandler)) + group.DELETE("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.deleteUserHandler)) + group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), httpserver.Handle(uc.deleteUserWebauthnCredentialHandler)) - group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups) + group.PUT("/users/:id/user-groups", authMiddleware.Add(), httpserver.Handle(uc.updateUserGroups)) - group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler) + group.GET("/users/:id/profile-picture.png", httpserver.Handle(uc.getUserProfilePictureHandler)) - group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler) - group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler) + group.PUT("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.updateUserProfilePictureHandler)) + group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserProfilePictureHandler)) - group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) - group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) + group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.resetUserProfilePictureHandler)) + group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.resetCurrentUserProfilePictureHandler)) } type UserController struct { @@ -61,21 +62,20 @@ type UserController struct { // @Param id path string true "User ID" // @Success 200 {array} dto.UserGroupDto // @Router /api/users/{id}/groups [get] -func (uc *UserController) getUserGroupsHandler(c *gin.Context) { +func (uc *UserController) getUserGroupsHandler(c *gin.Context) error { userID := c.Param("id") groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID) if err != nil { - _ = c.Error(err) - return + return err } var groupsDto []dto.UserGroupDto if err := dto.MapStructList(groups, &groupsDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, groupsDto) + return nil } // listUserWebauthnCredentialsHandler godoc @@ -85,27 +85,25 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) { // @Param id path string true "User ID" // @Success 200 {array} dto.WebauthnCredentialDto // @Router /api/users/{id}/webauthn-credentials [get] -func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) { +func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) error { userID := c.Param("id") if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return + return err } credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID) if err != nil { - _ = c.Error(err) - return + return err } var credentialDtos []dto.WebauthnCredentialDto if err := dto.MapStructList(credentials, &credentialDtos); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, credentialDtos) + return nil } // listUsersHandler godoc @@ -119,26 +117,25 @@ func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.UserDto] // @Router /api/users [get] -func (uc *UserController) listUsersHandler(c *gin.Context) { +func (uc *UserController) listUsersHandler(c *gin.Context) error { searchTerm := c.Query("search") listRequestOptions := utils.ParseListRequestOptions(c) users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } var usersDto []dto.UserDto if err := dto.MapStructList(users, &usersDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{ Data: usersDto, Pagination: pagination, }) + return nil } // getUserHandler godoc @@ -148,20 +145,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) { // @Param id path string true "User ID" // @Success 200 {object} dto.UserDto // @Router /api/users/{id} [get] -func (uc *UserController) getUserHandler(c *gin.Context) { +func (uc *UserController) getUserHandler(c *gin.Context) error { user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id")) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, userDto) + return nil } // getCurrentUserHandler godoc @@ -170,20 +166,19 @@ func (uc *UserController) getUserHandler(c *gin.Context) { // @Tags Users // @Success 200 {object} dto.UserDto // @Router /api/users/me [get] -func (uc *UserController) getCurrentUserHandler(c *gin.Context) { +func (uc *UserController) getCurrentUserHandler(c *gin.Context) error { user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID")) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, userDto) + return nil } // deleteUserHandler godoc @@ -193,19 +188,18 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) { // @Param id path string true "User ID" // @Success 204 "No Content" // @Router /api/users/{id} [delete] -func (uc *UserController) deleteUserHandler(c *gin.Context) { +func (uc *UserController) deleteUserHandler(c *gin.Context) error { dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } if err := uc.userService.DeleteUser(c.Request.Context(), dbConfig, c.Param("id"), false); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // deleteUserWebauthnCredentialHandler godoc @@ -216,7 +210,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) { // @Param credentialId path string true "Credential ID" // @Success 204 "No Content" // @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete] -func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) { +func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) error { err := uc.webAuthnService.DeleteCredential( c.Request.Context(), c.Param("id"), @@ -226,11 +220,11 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) { c.GetString("userID"), ) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // createUserHandler godoc @@ -240,32 +234,29 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) { // @Param user body dto.UserCreateDto true "User information" // @Success 201 {object} dto.UserDto // @Router /api/users [post] -func (uc *UserController) createUserHandler(c *gin.Context) { +func (uc *UserController) createUserHandler(c *gin.Context) error { dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input dto.UserCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } user, err := uc.userService.CreateUser(c.Request.Context(), dbConfig, input) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, userDto) + return nil } // updateUserHandler godoc @@ -276,8 +267,8 @@ func (uc *UserController) createUserHandler(c *gin.Context) { // @Param user body dto.UserCreateDto true "User information" // @Success 200 {object} dto.UserDto // @Router /api/users/{id} [put] -func (uc *UserController) updateUserHandler(c *gin.Context) { - uc.updateUser(c, false) +func (uc *UserController) updateUserHandler(c *gin.Context) error { + return uc.updateUser(c, false) } // updateCurrentUserHandler godoc @@ -287,8 +278,8 @@ func (uc *UserController) updateUserHandler(c *gin.Context) { // @Param user body dto.UserCreateDto true "User information" // @Success 200 {object} dto.UserDto // @Router /api/users/me [put] -func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { - uc.updateUser(c, true) +func (uc *UserController) updateCurrentUserHandler(c *gin.Context) error { + return uc.updateUser(c, true) } // getUserProfilePictureHandler godoc @@ -299,13 +290,12 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { // @Param id path string true "User ID" // @Success 200 {file} binary "PNG image" // @Router /api/users/{id}/profile-picture.png [get] -func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { +func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) error { userID := c.Param("id") picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID) if err != nil { - _ = c.Error(err) - return + return err } if picture != nil { defer picture.Close() @@ -314,6 +304,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour) c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) + return nil } // updateUserProfilePictureHandler godoc @@ -326,26 +317,24 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { // @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)" // @Success 204 "No Content" // @Router /api/users/{id}/profile-picture [put] -func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) { +func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) error { userID := c.Param("id") - fileHeader, err := c.FormFile("file") + fileHeader, err := httpserver.FormFile(c, "file") if err != nil { - _ = c.Error(err) - return + return err } file, err := fileHeader.Open() if err != nil { - _ = c.Error(err) - return + return err } defer file.Close() if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updateCurrentUserProfilePictureHandler godoc @@ -357,26 +346,24 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) { // @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)" // @Success 204 "No Content" // @Router /api/users/me/profile-picture [put] -func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) { +func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) error { userID := c.GetString("userID") - fileHeader, err := c.FormFile("file") + fileHeader, err := httpserver.FormFile(c, "file") if err != nil { - _ = c.Error(err) - return + return err } file, err := fileHeader.Open() if err != nil { - _ = c.Error(err) - return + return err } defer file.Close() if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updateUserGroups godoc @@ -387,40 +374,36 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) // @Param groups body dto.UserUpdateUserGroupDto true "User group IDs" // @Success 200 {object} dto.UserDto // @Router /api/users/{id}/user-groups [put] -func (uc *UserController) updateUserGroups(c *gin.Context) { +func (uc *UserController) updateUserGroups(c *gin.Context) error { var input dto.UserUpdateUserGroupDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, userDto) + return nil } // updateUser is an internal helper method, not exposed as an API endpoint -func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { +func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) error { dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input dto.UserCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } var userID string @@ -432,17 +415,16 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { user, err := uc.userService.UpdateUser(c.Request.Context(), dbConfig, userID, input, updateOwnUser, false) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, userDto) + return nil } // resetUserProfilePictureHandler godoc @@ -453,15 +435,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { // @Param id path string true "User ID" // @Success 204 "No Content" // @Router /api/users/{id}/profile-picture [delete] -func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) { +func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) error { userID := c.Param("id") if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // resetCurrentUserProfilePictureHandler godoc @@ -471,13 +453,13 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) { // @Produce json // @Success 204 "No Content" // @Router /api/users/me/profile-picture [delete] -func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) { +func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) error { userID := c.GetString("userID") if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index 5ecfd319..8545b2aa 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -25,13 +26,13 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A userGroupsGroup := group.Group("/user-groups") userGroupsGroup.Use(authMiddleware.Add()) { - userGroupsGroup.GET("", ugc.list) - userGroupsGroup.GET("/:id", ugc.get) - userGroupsGroup.POST("", ugc.create) - userGroupsGroup.PUT("/:id", ugc.update) - userGroupsGroup.DELETE("/:id", ugc.delete) - userGroupsGroup.PUT("/:id/users", ugc.updateUsers) - userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients) + userGroupsGroup.GET("", httpserver.Handle(ugc.list)) + userGroupsGroup.GET("/:id", httpserver.Handle(ugc.get)) + userGroupsGroup.POST("", httpserver.Handle(ugc.create)) + userGroupsGroup.PUT("/:id", httpserver.Handle(ugc.update)) + userGroupsGroup.DELETE("/:id", httpserver.Handle(ugc.delete)) + userGroupsGroup.PUT("/:id/users", httpserver.Handle(ugc.updateUsers)) + userGroupsGroup.PUT("/:id/allowed-oidc-clients", httpserver.Handle(ugc.updateAllowedOidcClients)) } } @@ -51,14 +52,13 @@ type UserGroupController struct { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto] // @Router /api/user-groups [get] -func (ugc *UserGroupController) list(c *gin.Context) { +func (ugc *UserGroupController) list(c *gin.Context) error { searchTerm := c.Query("search") listRequestOptions := utils.ParseListRequestOptions(c) groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } // Map the user groups to DTOs @@ -66,13 +66,11 @@ func (ugc *UserGroupController) list(c *gin.Context) { for i, group := range groups { var groupDto dto.UserGroupMinimalDto if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + return err } groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID) if err != nil { - _ = c.Error(err) - return + return err } groupsDto[i] = groupDto } @@ -81,6 +79,7 @@ func (ugc *UserGroupController) list(c *gin.Context) { Data: groupsDto, Pagination: pagination, }) + return nil } // get godoc @@ -92,20 +91,19 @@ func (ugc *UserGroupController) list(c *gin.Context) { // @Param id path string true "User Group ID" // @Success 200 {object} dto.UserGroupDto // @Router /api/user-groups/{id} [get] -func (ugc *UserGroupController) get(c *gin.Context) { +func (ugc *UserGroupController) get(c *gin.Context) error { group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id")) if err != nil { - _ = c.Error(err) - return + return err } var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, groupDto) + return nil } // create godoc @@ -117,26 +115,24 @@ func (ugc *UserGroupController) get(c *gin.Context) { // @Param userGroup body dto.UserGroupCreateDto true "User group information" // @Success 201 {object} dto.UserGroupDto "Created user group" // @Router /api/user-groups [post] -func (ugc *UserGroupController) create(c *gin.Context) { +func (ugc *UserGroupController) create(c *gin.Context) error { var input dto.UserGroupCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } group, err := ugc.UserGroupService.Create(c.Request.Context(), input) if err != nil { - _ = c.Error(err) - return + return err } var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, groupDto) + return nil } // update godoc @@ -149,32 +145,29 @@ func (ugc *UserGroupController) create(c *gin.Context) { // @Param userGroup body dto.UserGroupCreateDto true "User group information" // @Success 200 {object} dto.UserGroupDto "Updated user group" // @Router /api/user-groups/{id} [put] -func (ugc *UserGroupController) update(c *gin.Context) { +func (ugc *UserGroupController) update(c *gin.Context) error { dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input dto.UserGroupCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, groupDto) + return nil } // delete godoc @@ -186,19 +179,18 @@ func (ugc *UserGroupController) update(c *gin.Context) { // @Param id path string true "User Group ID" // @Success 204 "No Content" // @Router /api/user-groups/{id} [delete] -func (ugc *UserGroupController) delete(c *gin.Context) { +func (ugc *UserGroupController) delete(c *gin.Context) error { dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // updateUsers godoc @@ -211,26 +203,24 @@ func (ugc *UserGroupController) delete(c *gin.Context) { // @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group" // @Success 200 {object} dto.UserGroupDto // @Router /api/user-groups/{id}/users [put] -func (ugc *UserGroupController) updateUsers(c *gin.Context) { +func (ugc *UserGroupController) updateUsers(c *gin.Context) error { var input dto.UserGroupUpdateUsersDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs) if err != nil { - _ = c.Error(err) - return + return err } var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, groupDto) + return nil } // updateAllowedOidcClients godoc @@ -243,24 +233,22 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) { // @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow" // @Success 200 {object} dto.UserGroupDto "Updated user group" // @Router /api/user-groups/{id}/allowed-oidc-clients [put] -func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) { +func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) error { var input dto.UserGroupUpdateAllowedOidcClientsDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input) if err != nil { - _ = c.Error(err) - return + return err } var userGroupDto dto.UserGroupDto if err := dto.MapStruct(userGroup, &userGroupDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, userGroupDto) + return nil } diff --git a/backend/internal/controller/version_controller.go b/backend/internal/controller/version_controller.go index 09679908..b094496c 100644 --- a/backend/internal/controller/version_controller.go +++ b/backend/internal/controller/version_controller.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -14,8 +15,8 @@ import ( // NewVersionController registers version-related routes. func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) { vc := &VersionController{versionService: versionService} - group.GET("/version/latest", vc.getLatestVersionHandler) - group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler) + group.GET("/version/latest", httpserver.Handle(vc.getLatestVersionHandler)) + group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(vc.getCurrentVersionHandler)) } type VersionController struct { @@ -28,11 +29,10 @@ type VersionController struct { // @Produce json // @Success 200 {object} map[string]string "Latest version information" // @Router /api/version/latest [get] -func (vc *VersionController) getLatestVersionHandler(c *gin.Context) { +func (vc *VersionController) getLatestVersionHandler(c *gin.Context) error { tag, err := vc.versionService.GetLatestVersion(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute) @@ -40,6 +40,7 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "latestVersion": tag, }) + return nil } // getCurrentVersionHandler godoc @@ -48,9 +49,9 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) { // @Produce json // @Success 200 {object} map[string]string "Current version information" // @Router /api/version/current [get] -func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) { - +func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) error { c.JSON(http.StatusOK, gin.H{ "currentVersion": common.Version, }) + return nil } diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index f92ce4b4..e6656938 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -21,8 +22,8 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi getCIMDURLAllowlist: getCIMDURLAllowlist, } - group.GET("/.well-known/jwks.json", wkc.jwksHandler) - group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler) + group.GET("/.well-known/jwks.json", httpserver.Handle(wkc.jwksHandler)) + group.GET("/.well-known/openid-configuration", httpserver.Handle(wkc.openIDConfigurationHandler)) } type WellKnownController struct { @@ -37,14 +38,14 @@ type WellKnownController struct { // @Produce json // @Success 200 {object} object "{ \"keys\": []interface{} }" // @Router /.well-known/jwks.json [get] -func (wkc *WellKnownController) jwksHandler(c *gin.Context) { +func (wkc *WellKnownController) jwksHandler(c *gin.Context) error { jwks, err := wkc.jwtService.GetPublicJWKSAsJSON() if err != nil { - _ = c.Error(err) - return + return err } c.Data(http.StatusOK, "application/json; charset=utf-8", jwks) + return nil } // openIDConfigurationHandler godoc @@ -53,13 +54,13 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) { // @Tags Well Known // @Success 200 {object} object "OpenID Connect configuration" // @Router /.well-known/openid-configuration [get] -func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { +func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) error { oidcConfig, err := wkc.computeOIDCConfiguration() if err != nil { - _ = c.Error(err) - return + return err } c.Data(http.StatusOK, "application/json; charset=utf-8", oidcConfig) + return nil } func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { diff --git a/backend/internal/devicelogin/handler.go b/backend/internal/devicelogin/handler.go index fd52c70b..d6c70d0b 100644 --- a/backend/internal/devicelogin/handler.go +++ b/backend/internal/devicelogin/handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -32,11 +33,10 @@ func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) * // @Produce json // @Success 201 {object} requestCreateDto "Created device login request" // @Router /api/device-login/requests [post] -func (h *handler) createRequest(c *gin.Context) { +func (h *handler) createRequest(c *gin.Context) error { request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent()) if err != nil { - _ = c.Error(err) - return + return err } verificationURI := h.baseURL + "/device" @@ -50,6 +50,7 @@ func (h *handler) createRequest(c *gin.Context) { ExpiresAt: request.ExpiresAt, Interval: PollingInterval, }) + return nil } // exchangeRequest godoc @@ -61,11 +62,10 @@ func (h *handler) createRequest(c *gin.Context) { // @Success 200 {object} dto.UserDto "Approved request exchanged for a user session" // @Success 202 "Authorization pending" // @Router /api/device-login/requests/{id}/exchange [post] -func (h *handler) exchangeRequest(c *gin.Context) { +func (h *handler) exchangeRequest(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } requestID := c.Param("id") @@ -76,20 +76,20 @@ func (h *handler) exchangeRequest(c *gin.Context) { if c.Request.Context().Err() != nil { // Context canceled = the client stopped the request // Nothing to do here - return + return c.Request.Context().Err() } - _ = c.Error(err) - return + return err } if status == RequestStatusPending { // Request is pending, so respond with a 202 c.Status(http.StatusAccepted) - return + return nil } maxAge := int(sessionDuration.Seconds()) cookie.AddAccessTokenCookie(c, maxAge, accessToken) c.JSON(http.StatusOK, dto.UserDto(user)) + return nil } // inspectRequest godoc @@ -101,21 +101,20 @@ func (h *handler) exchangeRequest(c *gin.Context) { // @Param request body verificationDto true "Device login code" // @Success 200 {object} verificationInfoDto "Device login request details" // @Router /api/device-login/verification [post] -func (h *handler) inspectRequest(c *gin.Context) { +func (h *handler) inspectRequest(c *gin.Context) error { var input verificationDto - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } info, err := h.service.Inspect(c.Request.Context(), input.Code) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, verificationInfoDto(info)) + return nil } // decideRequest godoc @@ -126,20 +125,19 @@ func (h *handler) inspectRequest(c *gin.Context) { // @Param decision body decisionDto true "Device login decision" // @Success 204 "No Content" // @Router /api/device-login/verification/decision [post] -func (h *handler) decideRequest(c *gin.Context) { +func (h *handler) decideRequest(c *gin.Context) error { var input decisionDto - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) err = h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go index 2b7ebced..bd96179f 100644 --- a/backend/internal/devicelogin/module.go +++ b/backend/internal/devicelogin/module.go @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -72,8 +73,8 @@ func New(deps Dependencies) (*Module, error) { // RegisterRoutes mounts the public exchange and authenticated verification endpoints func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, exchangeRateLimit, verificationRateLimit gin.HandlerFunc) { - apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest) - apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, m.handler.exchangeRequest) - apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest) - apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest) + apiGroup.POST("/device-login/requests", createRateLimit, httpserver.Handle(m.handler.createRequest)) + apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, httpserver.Handle(m.handler.exchangeRequest)) + apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.inspectRequest)) + apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.decideRequest)) } diff --git a/backend/internal/devicelogin/service.go b/backend/internal/devicelogin/service.go index 3e218797..7b143fe1 100644 --- a/backend/internal/devicelogin/service.go +++ b/backend/internal/devicelogin/service.go @@ -11,7 +11,7 @@ import ( "github.com/italypaleale/francis/actor" "gorm.io/gorm" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -154,7 +154,7 @@ func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenti func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) { if requestID == "" || deviceToken == "" || sessionDuration <= 0 { - return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired() } deviceTokenHash := utils.CreateSha256Hash(deviceToken) @@ -212,9 +212,9 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres case RequestStatusPending: // no-op case RequestStatusDenied: - return dto.UserDto{}, "", result.Status, &common.DeviceLoginDeniedError{} + return dto.UserDto{}, "", result.Status, apperror.DeviceLoginDenied() default: - return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{} + return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired() } select { @@ -230,7 +230,7 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error { if token == "" { - return &common.ReauthenticationRequiredError{} + return apperror.ReauthenticationRequired() } tx := s.db.WithContext(ctx).Begin() @@ -241,14 +241,13 @@ func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userI reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID) if err != nil { - var reauthenticationRequiredError *common.ReauthenticationRequiredError - if errors.As(err, &reauthenticationRequiredError) { - return &common.ReauthenticationRequiredError{} + if apperror.IsCode(err, apperror.CodeReauthenticationRequired) { + return apperror.ReauthenticationRequired() } return err } if time.Since(reauthenticatedAt) > reauthenticationMaxAge { - return &common.ReauthenticationRequiredError{} + return apperror.ReauthenticationRequired() } if err = tx.Commit().Error; err != nil { @@ -262,11 +261,11 @@ func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.Us err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error switch { case errors.Is(err, gorm.ErrRecordNotFound): - return model.User{}, dto.UserDto{}, &common.DeviceLoginRequestInvalidOrExpiredError{} + return model.User{}, dto.UserDto{}, apperror.DeviceLoginRequestInvalidOrExpired() case err != nil: return model.User{}, dto.UserDto{}, err case user.Disabled: - return model.User{}, dto.UserDto{}, &common.UserDisabledError{} + return model.User{}, dto.UserDto{}, apperror.UserDisabled() } var userDTO dto.UserDto @@ -320,9 +319,9 @@ func actorResultError(code requestActorResultCode) error { case requestActorResultCollision: return errors.New("unexpected live device login actor collision") case requestActorResultInvalid: - return &common.DeviceLoginRequestInvalidOrExpiredError{} + return apperror.DeviceLoginRequestInvalidOrExpired() case requestActorResultDenied: - return &common.DeviceLoginDeniedError{} + return apperror.DeviceLoginDenied() default: return fmt.Errorf("unsupported device login actor result %q", code) } diff --git a/backend/internal/devicelogin/service_test.go b/backend/internal/devicelogin/service_test.go index 918e8303..205b3dd3 100644 --- a/backend/internal/devicelogin/service_test.go +++ b/backend/internal/devicelogin/service_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -33,7 +33,7 @@ type fakeReauthenticationTokenConsumer struct { func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) { if token != f.expectedValue { - return time.Time{}, &common.ReauthenticationRequiredError{} + return time.Time{}, apperror.ReauthenticationRequired() } if !f.createdAt.IsZero() { return f.createdAt, nil @@ -200,8 +200,7 @@ func TestPendingAndDeniedRequests(t *testing.T) { require.NoError(t, err) user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) - var deniedError *common.DeviceLoginDeniedError - require.ErrorAs(t, err, &deniedError) + require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied)) require.Equal(t, RequestStatusDenied, status) require.Empty(t, user.ID) require.Empty(t, accessToken) @@ -228,8 +227,7 @@ func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) { select { case outcome := <-result: - var deniedError *common.DeviceLoginDeniedError - require.ErrorAs(t, outcome.err, &deniedError) + require.True(t, apperror.IsCode(outcome.err, apperror.CodeDeviceLoginDenied)) require.Equal(t, RequestStatusDenied, outcome.status) case <-time.After(2 * time.Second): t.Fatal("exchange did not observe the actor decision") @@ -282,8 +280,7 @@ func TestRejectsDisabledUserAtExchange(t *testing.T) { require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof")) _, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) - var disabledError *common.UserDisabledError - require.ErrorAs(t, err, &disabledError) + require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled)) require.Empty(t, accessToken) require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status) } @@ -322,13 +319,12 @@ func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) { require.NoError(t, err) err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "") - var reauthenticationError *common.ReauthenticationRequiredError - require.ErrorAs(t, err, &reauthenticationError) + require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) fixture.reauth.expectedValue = "stale-proof" fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute) err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof") - require.ErrorAs(t, err, &reauthenticationError) + require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status) } @@ -374,8 +370,7 @@ func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) { successfulTokens = append(successfulTokens, result.token) continue } - var invalidRequestError *common.DeviceLoginRequestInvalidOrExpiredError - require.ErrorAs(t, result.err, &invalidRequestError) + require.True(t, apperror.IsCode(result.err, apperror.CodeDeviceLoginExpired)) invalidExchanges++ } require.Equal(t, []string{"device-login-access-token"}, successfulTokens) @@ -422,8 +417,7 @@ func TestRequestStateSurvivesActorHostRestart(t *testing.T) { require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed ")) require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")) _, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration) - var deniedError *common.DeviceLoginDeniedError - require.ErrorAs(t, err, &deniedError) + require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied)) require.Equal(t, RequestStatusDenied, status) } @@ -527,8 +521,7 @@ func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorI func assertInvalidRequestError(t *testing.T, err error) { t.Helper() - var invalidError *common.DeviceLoginRequestInvalidOrExpiredError - require.ErrorAs(t, err, &invalidError) + require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginExpired)) } func persistentTestDependencies(db *gorm.DB) Dependencies { diff --git a/backend/internal/dto/dto_normalize.go b/backend/internal/dto/dto_normalize.go index b988e84e..25ed43b9 100644 --- a/backend/internal/dto/dto_normalize.go +++ b/backend/internal/dto/dto_normalize.go @@ -3,72 +3,83 @@ package dto import ( "reflect" - "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" "golang.org/x/text/unicode/norm" ) -// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag. +// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag func Normalize(obj any) { - v := reflect.ValueOf(obj) - if v.Kind() != reflect.Pointer || v.IsNil() { + normalizeValue(reflect.ValueOf(obj)) +} + +func normalizeValue(value reflect.Value) { + if !value.IsValid() { return } - v = v.Elem() - // Handle case where obj is a slice of models - if v.Kind() == reflect.Slice { - for i := 0; i < v.Len(); i++ { - elem := v.Index(i) - if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct { - Normalize(elem.Interface()) - } else if elem.Kind() == reflect.Struct && elem.CanAddr() { - Normalize(elem.Addr().Interface()) - } + // Unwrap interfaces and pointers so nested DTOs share the same traversal + kind := value.Kind() + if kind == reflect.Interface || kind == reflect.Pointer { + if !value.IsNil() { + normalizeValue(value.Elem()) } return } - if v.Kind() != reflect.Struct { + // Walk collections because request DTOs may contain nested slices or arrays + if kind == reflect.Slice || kind == reflect.Array { + for i := range value.Len() { + normalizeValue(value.Index(i)) + } return } - // Iterate through all fields looking for those with the "unorm" tag - t := v.Type() -loop: - for i := range t.NumField() { - field := t.Field(i) + // Ignore scalar values because normalization is opt-in through struct field tags + if kind != reflect.Struct { + return + } - unormTag := field.Tag.Get("unorm") - if unormTag == "" { + // Normalize tagged fields directly and recursively inspect untagged nested values + valueType := value.Type() + for i := range value.NumField() { + field := value.Field(i) + form, tagged := normalizationForm(valueType.Field(i).Tag.Get("unorm")) + if tagged { + normalizeString(field, form) continue } - fv := v.Field(i) - if !fv.CanSet() || fv.Kind() != reflect.String { - continue - } - - var form norm.Form - switch unormTag { - case "nfc": - form = norm.NFC - case "nfkc": - form = norm.NFKC - case "nfd": - form = norm.NFD - case "nfkd": - form = norm.NFKD - default: - continue loop - } - - val := fv.String() - val = form.String(val) - fv.SetString(val) + normalizeValue(field) } } -func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error { - return ctx.ShouldBindWith(obj, binding.JSON) +func normalizationForm(tag string) (norm.Form, bool) { + switch tag { + case "nfc": + return norm.NFC, true + case "nfkc": + return norm.NFKC, true + case "nfd": + return norm.NFD, true + case "nfkd": + return norm.NFKD, true + default: + return 0, false + } +} + +func normalizeString(value reflect.Value, form norm.Form) { + // Dereference optional string fields while leaving nil values unchanged + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return + } + value = value.Elem() + } + + // Ignore incompatible or read-only fields because reflection cannot safely update them + if value.Kind() != reflect.String || !value.CanSet() { + return + } + + value.SetString(form.String(value.String())) } diff --git a/backend/internal/dto/user_dto_test.go b/backend/internal/dto/user_dto_test.go index 87b76b37..b9187e65 100644 --- a/backend/internal/dto/user_dto_test.go +++ b/backend/internal/dto/user_dto_test.go @@ -43,7 +43,7 @@ func TestUserCreateDto_Validate(t *testing.T) { LastName: "Doe", DisplayName: "John Doe", }, - wantErr: "Field validation for 'Username' failed on the 'required' tag", + wantErr: "Field validation for 'username' failed on the 'required' tag", }, { name: "missing first name", @@ -73,7 +73,7 @@ func TestUserCreateDto_Validate(t *testing.T) { LastName: "Doe", DisplayName: "John Doe", }, - wantErr: "Field validation for 'Username' failed on the 'username' tag", + wantErr: "Field validation for 'username' failed on the 'username' tag", }, { name: "invalid email", @@ -84,7 +84,7 @@ func TestUserCreateDto_Validate(t *testing.T) { LastName: "Doe", DisplayName: "John Doe", }, - wantErr: "Field validation for 'Email' failed on the 'email' tag", + wantErr: "Field validation for 'email' failed on the 'email' tag", }, { name: "first name too short", @@ -106,7 +106,7 @@ func TestUserCreateDto_Validate(t *testing.T) { LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", DisplayName: "John Doe", }, - wantErr: "Field validation for 'LastName' failed on the 'max' tag", + wantErr: "Field validation for 'lastName' failed on the 'max' tag", }, } diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 0f4ab9f6..f0ae5ad5 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -2,6 +2,7 @@ package dto import ( "net/url" + "reflect" "regexp" "strings" "time" @@ -24,6 +25,15 @@ var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") func init() { engine := binding.Validator.Engine().(*validator.Validate) + // Use JSON tags to keep client-visible validation field names stable + engine.RegisterTagNameFunc(func(field reflect.StructField) string { + name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0] + if name == "" || name == "-" { + return field.Name + } + return name + }) + // Maximum allowed value for TTLs const maxTTL = 31 * 24 * time.Hour diff --git a/backend/internal/email/module.go b/backend/internal/email/module.go index bb8a6a5f..1f6b2230 100644 --- a/backend/internal/email/module.go +++ b/backend/internal/email/module.go @@ -16,6 +16,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/resources" @@ -69,12 +70,15 @@ func (m *Module) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfi WithContext(ctx). First(&user, "id = ?", recipientUserID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.UserNotFound() + } if err != nil { return err } if user.Email == nil { - return &common.UserEmailNotSetError{} + return apperror.UserEmailNotSet() } return send(ctx, m, dbConfig, address{ diff --git a/backend/internal/email/module_test.go b/backend/internal/email/module_test.go index bc5f099d..21b88767 100644 --- a/backend/internal/email/module_test.go +++ b/backend/internal/email/module_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -134,8 +134,16 @@ func TestSendTestEmailRequiresUserEmail(t *testing.T) { require.NoError(t, err) err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, user.ID) - var emailNotSetError *common.UserEmailNotSetError - require.ErrorAs(t, err, &emailNotSetError) + require.True(t, apperror.IsCode(err, apperror.CodeUserEmailNotSet)) +} + +func TestSendTestEmailRejectsMissingUser(t *testing.T) { + module, err := New(testutils.NewDatabaseForTest(t)) + require.NoError(t, err) + + err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, "missing-user") + + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) } func TestSMTPConnStringPreservesConfiguration(t *testing.T) { diff --git a/backend/internal/emailverification/handler.go b/backend/internal/emailverification/handler.go index 9f363ac9..d7573e3d 100644 --- a/backend/internal/emailverification/handler.go +++ b/backend/internal/emailverification/handler.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" ) type handler struct { @@ -25,20 +26,19 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler { // @Produce json // @Success 204 "No Content" // @Router /api/users/me/send-email-verification [post] -func (h *handler) send(c *gin.Context) { +func (h *handler) send(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } err = h.service.Send(c.Request.Context(), dbConfig, c.GetString("userID")) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // verify godoc @@ -48,18 +48,17 @@ func (h *handler) send(c *gin.Context) { // @Param body body dto.EmailVerificationDto true "Email verification token" // @Success 204 "No Content" // @Router /api/users/me/verify-email [post] -func (h *handler) verify(c *gin.Context) { +func (h *handler) verify(c *gin.Context) error { var input dto.EmailVerificationDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } err := h.service.Verify(c.Request.Context(), c.GetString("userID"), input.Token) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/emailverification/module.go b/backend/internal/emailverification/module.go index b67b92e6..19abbd38 100644 --- a/backend/internal/emailverification/module.go +++ b/backend/internal/emailverification/module.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" ) type AppConfigResolver interface { @@ -45,6 +46,6 @@ func New(deps Dependencies) (*Module, error) { // RegisterRoutes mounts the email verification endpoints func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, sendRateLimit, verifyRateLimit gin.HandlerFunc) { - apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, m.handler.send) - apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, m.handler.verify) + apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, httpserver.Handle(m.handler.send)) + apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, httpserver.Handle(m.handler.verify)) } diff --git a/backend/internal/emailverification/service.go b/backend/internal/emailverification/service.go index 47ce2701..6163f67c 100644 --- a/backend/internal/emailverification/service.go +++ b/backend/internal/emailverification/service.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -50,7 +50,7 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, return err } if user.Email == nil { - return &common.UserEmailNotSetError{} + return apperror.UserEmailNotSet() } token, err := utils.GenerateRandomAlphanumericString(32) @@ -104,7 +104,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error { return fmt.Errorf("error decoding email verification actor response: %w", err) } if result.Status != consumeOK { - return &common.InvalidEmailVerificationTokenError{} + return apperror.InvalidEmailVerificationToken() } // Update the user's email_verified field in the database @@ -123,7 +123,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error { return update.Error } if update.RowsAffected != 1 { - return &common.InvalidEmailVerificationTokenError{} + return apperror.InvalidEmailVerificationToken() } return nil diff --git a/backend/internal/emailverification/service_test.go b/backend/internal/emailverification/service_test.go index b1a48d67..132f5371 100644 --- a/backend/internal/emailverification/service_test.go +++ b/backend/internal/emailverification/service_test.go @@ -13,7 +13,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -142,8 +142,7 @@ func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) { }).Error) err := service.Verify(t.Context(), user.ID, token) - var invalidTokenError *common.InvalidEmailVerificationTokenError - require.ErrorAs(t, err, &invalidTokenError) + require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid)) var updated model.User require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error) @@ -162,8 +161,7 @@ func TestVerifyDoesNotConsumeStateForWrongToken(t *testing.T) { require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) err := service.Verify(t.Context(), user.ID, "wrong-verification-code") - var invalidTokenError *common.InvalidEmailVerificationTokenError - require.ErrorAs(t, err, &invalidTokenError) + require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid)) var state State require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state)) @@ -187,8 +185,7 @@ func TestVerifyRejectsExpiredToken(t *testing.T) { }, time.Second, time.Millisecond) err := service.Verify(t.Context(), user.ID, token) - var invalidTokenError *common.InvalidEmailVerificationTokenError - require.ErrorAs(t, err, &invalidTokenError) + require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid)) var updated model.User require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error) diff --git a/backend/internal/httpserver/binding.go b/backend/internal/httpserver/binding.go new file mode 100644 index 00000000..08d7040a --- /dev/null +++ b/backend/internal/httpserver/binding.go @@ -0,0 +1,69 @@ +package httpserver + +import ( + "errors" + "io" + "mime/multipart" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +// BindJSON binds and normalizes a JSON request while distinguishing invalid input from internal failures +func BindJSON(c *gin.Context, value any) error { + err := classifyBindingError(c.ShouldBindJSON(value)) + if err != nil { + return err + } + + dto.Normalize(value) + return nil +} + +// BindOptionalJSON accepts an empty body while normalizing valid input and classifying malformed JSON as invalid input +func BindOptionalJSON(c *gin.Context, value any) error { + err := c.ShouldBindJSON(value) + if errors.Is(err, io.EOF) { + return nil + } + if err = classifyBindingError(err); err != nil { + return err + } + + dto.Normalize(value) + return nil +} + +// FormFile returns an uploaded file while classifying a missing field as request validation +func FormFile(c *gin.Context, field string) (*multipart.FileHeader, error) { + file, err := c.FormFile(field) + if errors.Is(err, http.ErrMissingFile) { + return nil, apperror.MissingField(field) + } + if err != nil { + return nil, apperror.InvalidRequestBody(err) + } + + return file, nil +} + +func classifyBindingError(err error) error { + if err == nil { + return nil + } + + if _, ok := errors.AsType[validator.ValidationErrors](err); ok { + return err + } + + if _, ok := errors.AsType[binding.SliceValidationError](err); ok { + return err + } + + return apperror.InvalidRequestBody(err) +} diff --git a/backend/internal/httpserver/binding_test.go b/backend/internal/httpserver/binding_test.go new file mode 100644 index 00000000..784cc38d --- /dev/null +++ b/backend/internal/httpserver/binding_test.go @@ -0,0 +1,85 @@ +package httpserver + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/stretchr/testify/require" + "golang.org/x/text/unicode/norm" +) + +func TestBindJSONClassifiesMalformedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"name":`)) + c.Request.Header.Set("Content-Type", "application/json") + + var input struct { + Name string `json:"name"` + } + err := BindJSON(c, &input) + + require.True(t, apperror.IsCode(err, apperror.CodeInvalidRequestBody)) + require.Contains(t, err.Error(), "unexpected EOF") + var appErr *apperror.Error + require.ErrorAs(t, err, &appErr) + require.NotContains(t, appErr.ClientMessage(), "unexpected end") +} + +func TestBindJSONNormalizesTaggedFieldsRecursively(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/", + strings.NewReader(`{"name":"Cafe\u0301","email":"user@cafe\u0301.example","items":[{"label":"Re\u0301sume\u0301"}]}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + + type embeddedInput struct { + Name string `json:"name" unorm:"nfc"` + } + type itemInput struct { + Label string `json:"label" unorm:"nfc"` + } + var input struct { + embeddedInput + Email *string `json:"email" unorm:"nfc"` + Items []itemInput `json:"items"` + } + err := BindJSON(c, &input) + + require.NoError(t, err) + require.Equal(t, norm.NFC.String("Café"), input.Name) + require.NotNil(t, input.Email) + require.Equal(t, norm.NFC.String("user@café.example"), *input.Email) + require.Equal(t, norm.NFC.String("Résumé"), input.Items[0].Label) +} + +func TestFormFileClassifiesMissingField(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.Close()) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", &body) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + _, err := FormFile(c, "file") + + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) + var appErr *apperror.Error + require.ErrorAs(t, err, &appErr) + require.Equal(t, []apperror.FieldError{{ + Field: "file", + Code: "required", + Message: "is required", + }}, appErr.Fields()) +} diff --git a/backend/internal/httpserver/handler.go b/backend/internal/httpserver/handler.go new file mode 100644 index 00000000..6690775d --- /dev/null +++ b/backend/internal/httpserver/handler.go @@ -0,0 +1,46 @@ +package httpserver + +import ( + "context" + "errors" + "fmt" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" +) + +// HandlerFunc is an application HTTP handler that returns failures to the shared error middleware +type HandlerFunc func(*gin.Context) error + +// Handle adapts an error-returning application handler to Gin +func Handle(handler HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + recovered := recover() + if recovered == nil { + return + } + + // Preserve net/http's intentional request-abort behavior without logging it as an application panic + if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(recovered) + } + + // Keep recovered errors out of the unwrap chain so every panic is reported as an internal failure + err := fmt.Errorf("panic in HTTP handler (%T): %v\n%s", recovered, recovered, debug.Stack()) + _ = c.Error(err) + c.Abort() + }() + + if err := handler(c); err != nil { + if errors.Is(err, context.Canceled) { + c.Abort() + return + } + + _ = c.Error(err) + c.Abort() + } + } +} diff --git a/backend/internal/httpserver/handler_test.go b/backend/internal/httpserver/handler_test.go new file mode 100644 index 00000000..bbf973e9 --- /dev/null +++ b/backend/internal/httpserver/handler_test.go @@ -0,0 +1,64 @@ +package httpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestHandleAttachesAndAbortsOnError(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + expected := errors.New("request failed") + + Handle(func(*gin.Context) error { + return expected + })(c) + + require.True(t, c.IsAborted()) + require.Len(t, c.Errors, 1) + require.ErrorIs(t, c.Errors[0], expected) +} + +func TestHandleIgnoresCanceledRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + Handle(func(*gin.Context) error { + return context.Canceled + })(c) + + require.True(t, c.IsAborted()) + require.Empty(t, c.Errors) +} + +func TestHandleRecoversPanicsWithStack(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + Handle(func(*gin.Context) error { + panic("private panic details") + })(c) + + require.True(t, c.IsAborted()) + require.Len(t, c.Errors, 1) + require.ErrorContains(t, c.Errors[0], "panic in HTTP handler (string): private panic details") + require.ErrorContains(t, c.Errors[0], "TestHandleRecoversPanicsWithStack") +} + +func TestHandlePreservesAbortHandlerPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + require.PanicsWithValue(t, http.ErrAbortHandler, func() { + Handle(func(*gin.Context) error { + panic(http.ErrAbortHandler) + })(c) + }) + require.Empty(t, c.Errors) +} diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go index da9a0cb3..1ac11919 100644 --- a/backend/internal/middleware/api_key_auth.go +++ b/backend/internal/middleware/api_key_auth.go @@ -3,7 +3,7 @@ package middleware import ( "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/apikey" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -39,15 +39,15 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey) if err != nil { - return "", false, &common.NotSignedInError{} + return "", false, apperror.NotSignedIn() } if user.Disabled { - return "", false, &common.UserDisabledError{} + return "", false, apperror.UserDisabled() } if adminRequired && !user.IsAdmin { - return "", false, &common.MissingPermissionError{} + return "", false, apperror.MissingPermission() } return user.ID, user.IsAdmin, nil diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index 62b89897..8957af47 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -1,11 +1,9 @@ package middleware import ( - "errors" - "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/apikey" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -88,8 +86,8 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc { return } - // If JWT auth failed and the error is not a NotSignedInError, abort the request - if !errors.Is(err, &common.NotSignedInError{}) { + // If JWT auth failed for a reason other than missing credentials, abort the request + if !apperror.IsCode(err, apperror.CodeNotSignedIn) { c.Abort() _ = c.Error(err) return @@ -103,7 +101,7 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc { c.Abort() if c.GetHeader("X-API-Key") != "" { - _ = c.Error(&common.APIKeyAuthNotAllowedError{}) + _ = c.Error(apperror.APIKeyAuthNotAllowed()) return } _ = c.Error(err) diff --git a/backend/internal/middleware/error_handler.go b/backend/internal/middleware/error_handler.go index 9c34b6af..f2acb9b6 100644 --- a/backend/internal/middleware/error_handler.go +++ b/backend/internal/middleware/error_handler.go @@ -1,125 +1,278 @@ package middleware import ( + "context" "errors" "fmt" + "log/slog" "net/http" + "reflect" "strings" + "time" + "unicode" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" - "github.com/pocket-id/pocket-id/backend/internal/common" - "gorm.io/gorm" + "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "go.opentelemetry.io/otel/trace" ) +const requestIDHeader = "X-Request-ID" + +type requestIDContextKey struct{} +type requestErrorCodeContextKey struct{} + +// RequestID returns the identifier assigned to the current request +func RequestID(c *gin.Context) string { + if c == nil { + return "" + } + + requestID, exists := c.Get(requestIDContextKey{}) + if !exists { + return "" + } + + value, ok := requestID.(string) + if !ok { + return "" + } + + return value +} + +// RequestErrorCode returns the stable code of the error handled for the current request +func RequestErrorCode(c *gin.Context) apperror.Code { + if c == nil { + return "" + } + + code, exists := c.Get(requestErrorCodeContextKey{}) + if !exists { + return "" + } + + value, ok := code.(apperror.Code) + if !ok { + return "" + } + + return value +} + type ErrorHandlerMiddleware struct{} func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware { return &ErrorHandlerMiddleware{} } +type classifiedError struct { + code apperror.Code + status int + message string + details map[string]string + fields []apperror.FieldError + retryAfter time.Duration +} + +// Add records a request ID before executing the request and serializes the first returned error afterward func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { + requestID := uuid.NewString() + c.Set(requestIDContextKey{}, requestID) + c.Header(requestIDHeader, requestID) + c.Next() - for _, err := range c.Errors { - // Check for record not found errors - if errors.Is(err, gorm.ErrRecordNotFound) { - errorResponse(c, http.StatusNotFound, "Record not found") - return - } - - // Check for validation errors - var validationErrors validator.ValidationErrors - if errors.As(err, &validationErrors) { - message := handleValidationError(validationErrors) - errorResponse(c, http.StatusBadRequest, message) - return - } - - // Check for slice validation errors - svErr, ok := errors.AsType[binding.SliceValidationError](err) - if ok { - if errors.As(svErr[0], &validationErrors) { - message := handleValidationError(validationErrors) - errorResponse(c, http.StatusBadRequest, message) - return - } - } - - // AppError with description - appDescErr, ok := errors.AsType[common.AppErrorDescription](err) - if ok { - errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description()) - return - } - - // AppError (without description) - appErr, ok := errors.AsType[common.AppError](err) - if ok { - errorResponse(c, appErr.HttpStatusCode(), appErr.Error()) - return - } - - c.JSON(http.StatusInternalServerError, errorResponseBody{ - Error: "Something went wrong", - }) + if len(c.Errors) == 0 { + return } + + err := c.Errors[0].Err + classified := classifyError(err) + c.Set(requestErrorCodeContextKey{}, classified.code) + logRequestError(c, err, classified, requestID) + + if c.Writer.Written() { + return + } + + if classified.retryAfter > 0 { + c.Header("Retry-After", formatRetryAfter(classified.retryAfter)) + } + writeErrorResponse(c, classified, requestID) } } type errorResponseBody struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` + Error string `json:"error"` + Code apperror.Code `json:"code"` + Details map[string]any `json:"details,omitempty"` + RequestID string `json:"request_id,omitempty"` } -func errorResponse(c *gin.Context, statusCode int, message string) { - // Capitalize the first letter of the message - message = strings.ToUpper(message[:1]) + message[1:] - c.JSON(statusCode, errorResponseBody{ - Error: message, - }) -} - -func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) { - // Capitalize the first letter of the message - message = strings.ToUpper(message[:1]) + message[1:] - c.JSON(statusCode, errorResponseBody{ - Error: message, - ErrorDescription: description, - }) -} - -func handleValidationError(validationErrors validator.ValidationErrors) string { - var errorMessages []string - - for _, ve := range validationErrors { - fieldName := ve.Field() - var errorMessage string - switch ve.Tag() { - case "required": - errorMessage = fmt.Sprintf("%s is required", fieldName) - case "email": - errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName) - case "username": - errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName) - case "url": - errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName) - case "resource_uri": - errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName) - case "min": - errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param()) - case "max": - errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param()) - default: - errorMessage = fmt.Sprintf("%s is invalid", fieldName) +func classifyError(err error) classifiedError { + var structuredErr *apperror.Error + if errors.As(err, &structuredErr) && structuredErr != nil { + return classifiedError{ + code: structuredErr.Code(), + status: normalizeStatus(structuredErr.HTTPStatus()), + message: capitalizeFirst(structuredErr.ClientMessage()), + details: structuredErr.Details(), + fields: structuredErr.Fields(), + retryAfter: structuredErr.RetryAfter(), } - - errorMessages = append(errorMessages, errorMessage) } - // Join all the error messages into a single string - combinedErrors := strings.Join(errorMessages, ", ") + if errors.Is(err, context.DeadlineExceeded) { + return classifiedError{ + code: apperror.CodeRequestTimeout, + status: http.StatusGatewayTimeout, + message: "Request timed out", + } + } - return combinedErrors + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) && len(validationErrors) > 0 { + return classifiedValidationError(validationErrors) + } + + var sliceValidationErrors binding.SliceValidationError + if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 { + if errors.As(sliceValidationErrors[0], &validationErrors) { + return classifiedValidationError(validationErrors) + } + } + + return classifiedError{ + code: apperror.CodeInternal, + status: http.StatusInternalServerError, + message: "Something went wrong", + } +} + +func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError { + fields := make([]apperror.FieldError, 0, len(validationErrors)) + messages := make([]string, 0, len(validationErrors)) + + for _, validationError := range validationErrors { + fieldName := validationError.Field() + code, message := validationFieldError(validationError) + fields = append(fields, apperror.FieldError{ + Field: fieldName, + Code: code, + Message: message, + }) + messages = append(messages, fieldName+" "+message) + } + + return classifiedError{ + code: apperror.CodeValidationFailed, + status: http.StatusBadRequest, + message: capitalizeFirst(strings.Join(messages, ", ")), + fields: fields, + } +} + +func validationFieldError(validationError validator.FieldError) (string, string) { + switch validationError.Tag() { + case "required": + return "required", "is required" + case "email": + return "invalid_format", "must be a valid email address" + case "username": + return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character" + case "url": + return "invalid_format", "must be a valid URL" + case "resource_uri": + return "invalid_format", "must be an absolute URI without whitespace or a fragment" + case "min": + return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param()) + case "max": + return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param()) + default: + return validationError.Tag(), "is invalid" + } +} + +func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) { + details := make(map[string]any, len(classified.details)+1) + for key, value := range classified.details { + details[key] = value + } + if len(classified.fields) > 0 { + details["fields"] = classified.fields + } + if len(details) == 0 { + details = nil + } + + response := errorResponseBody{ + Error: classified.message, + Code: classified.code, + Details: details, + RequestID: requestID, + } + + c.JSON(classified.status, response) +} + +func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) { + if classified.status < http.StatusInternalServerError { + return + } + + attrs := []any{ + slog.String("error_code", string(classified.code)), + slog.String("error_type", errorTypeName(err)), + slog.Int("http_status", classified.status), + slog.String("request_id", requestID), + slog.String("http_method", c.Request.Method), + slog.String("http_path", c.Request.URL.Path), + slog.Any("error", err), + } + if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() { + attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String())) + } + + slog.ErrorContext(c.Request.Context(), "Request failed", attrs...) +} + +func errorTypeName(err error) string { + if err == nil { + return "" + } + + return reflect.TypeOf(err).String() +} + +func normalizeStatus(status int) int { + if status < http.StatusBadRequest || status > 599 { + return http.StatusInternalServerError + } + + return status +} + +func formatRetryAfter(retryAfter time.Duration) string { + seconds := int(retryAfter / time.Second) + if retryAfter%time.Second != 0 { + seconds++ + } + if seconds < 1 { + seconds = 1 + } + + return fmt.Sprintf("%d", seconds) +} + +func capitalizeFirst(message string) string { + runes := []rune(message) + if len(runes) == 0 { + return message + } + + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) } diff --git a/backend/internal/middleware/error_handler_test.go b/backend/internal/middleware/error_handler_test.go new file mode 100644 index 00000000..2ff97db9 --- /dev/null +++ b/backend/internal/middleware/error_handler_test.go @@ -0,0 +1,180 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestErrorHandlerMiddlewareStructuredError(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + var handlerRequestID string + router.GET("/users/1", httpserver.Handle(func(c *gin.Context) error { + handlerRequestID = RequestID(c) + cause := errors.New("database connection details") + return apperror.Wrap(cause, apperror.CodeUserNotFound, http.StatusNotFound, "User not found") + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/1", nil) + router.ServeHTTP(recorder, request) + + var body struct { + Error string `json:"error"` + Code apperror.Code `json:"code"` + Details map[string]string `json:"details"` + RequestID string `json:"request_id"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Equal(t, "User not found", body.Error) + require.Equal(t, apperror.CodeUserNotFound, body.Code) + require.Empty(t, body.Details) + require.NotEmpty(t, body.RequestID) + require.Equal(t, body.RequestID, handlerRequestID) + require.Equal(t, body.RequestID, recorder.Header().Get(requestIDHeader)) + require.NotContains(t, recorder.Body.String(), "database connection details") +} + +func TestErrorHandlerMiddlewareHidesUnexpectedError(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.GET("/failure", func(c *gin.Context) { + _ = c.Error(errors.New("private database details")) + c.Abort() + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/failure", nil) + router.ServeHTTP(recorder, request) + + var body map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.Equal(t, "Something went wrong", body["error"]) + require.Equal(t, string(apperror.CodeInternal), body["code"]) + require.NotContains(t, recorder.Body.String(), "private database details") + require.NotEmpty(t, body["request_id"]) +} + +func TestErrorHandlerMiddlewareHidesRecoveredPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.GET("/panic", httpserver.Handle(func(*gin.Context) error { + panic("private panic details") + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", nil) + router.ServeHTTP(recorder, request) + + var body map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.Equal(t, "Something went wrong", body["error"]) + require.Equal(t, string(apperror.CodeInternal), body["code"]) + require.NotContains(t, recorder.Body.String(), "private panic details") + require.NotEmpty(t, body["request_id"]) +} + +func TestErrorHandlerMiddlewareIncludesSafeDetails(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.GET("/users", func(c *gin.Context) { + _ = c.Error(apperror.AlreadyInUse("email")) + c.Abort() + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users", nil) + router.ServeHTTP(recorder, request) + + var body struct { + Details map[string]string `json:"details"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, map[string]string{"property": "email"}, body.Details) +} + +func TestErrorHandlerMiddlewareSetsRetryAfter(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.GET("/limited", func(c *gin.Context) { + _ = c.Error(apperror.TooManyRequests().WithRetryAfter(1500 * time.Millisecond)) + c.Abort() + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/limited", nil) + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusTooManyRequests, recorder.Code) + require.Equal(t, "2", recorder.Header().Get("Retry-After")) +} + +func TestValidationResponseUsesJSONFieldNames(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.POST("/users", httpserver.Handle(func(c *gin.Context) error { + var input dto.UserCreateDto + return httpserver.BindJSON(c, &input) + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/users", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + var body struct { + Details struct { + Fields []apperror.FieldError `json:"fields"` + } `json:"details"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, body.Details.Fields, apperror.FieldError{ + Field: "username", + Code: "required", + Message: "is required", + }) +} + +func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) { + classified := classifyError(gorm.ErrRecordNotFound) + + require.Equal(t, apperror.CodeInternal, classified.code) + require.Equal(t, http.StatusInternalServerError, classified.status) +} + +func TestClassifyDeadlineAsRequestTimeout(t *testing.T) { + classified := classifyError(context.DeadlineExceeded) + + require.Equal(t, apperror.CodeRequestTimeout, classified.code) + require.Equal(t, http.StatusGatewayTimeout, classified.status) + require.Equal(t, "Request timed out", classified.message) +} diff --git a/backend/internal/middleware/file_size_limit.go b/backend/internal/middleware/file_size_limit.go index c5581ac1..39049a10 100644 --- a/backend/internal/middleware/file_size_limit.go +++ b/backend/internal/middleware/file_size_limit.go @@ -1,11 +1,13 @@ package middleware import ( + "errors" "fmt" + "mime/multipart" "net/http" "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" ) type FileSizeLimitMiddleware struct{} @@ -18,7 +20,13 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc { return func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) if err := c.Request.ParseMultipartForm(maxSize); err != nil { - err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)} + // Classify only size-limit failures as file_too_large so malformed multipart bodies remain invalid requests + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) || errors.Is(err, multipart.ErrMessageTooLarge) { + err = apperror.FileTooLarge(formatFileSize(maxSize)) + } else { + err = apperror.InvalidRequestBody(err) + } _ = c.Error(err) c.Abort() return diff --git a/backend/internal/middleware/file_size_limit_test.go b/backend/internal/middleware/file_size_limit_test.go new file mode 100644 index 00000000..c53a0a84 --- /dev/null +++ b/backend/internal/middleware/file_size_limit_test.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/stretchr/testify/require" +) + +func TestFileSizeLimitMiddlewareClassifiesMultipartErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("oversized body", func(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "large.png") + require.NoError(t, err) + _, err = part.Write(bytes.Repeat([]byte("x"), 128)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + recorder := serveMultipartRequest(t, body.Bytes(), writer.FormDataContentType(), 64) + + require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + require.Equal(t, apperror.CodeFileTooLarge, responseCode(t, recorder)) + }) + + t.Run("malformed body", func(t *testing.T) { + recorder := serveMultipartRequest(t, []byte("not multipart"), "multipart/form-data; boundary=missing", 1024) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, apperror.CodeInvalidRequestBody, responseCode(t, recorder)) + }) +} + +func serveMultipartRequest(t *testing.T, body []byte, contentType string, maxSize int64) *httptest.ResponseRecorder { + t.Helper() + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.POST("/", NewFileSizeLimitMiddleware().Add(maxSize), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", bytes.NewReader(body)) + request.Header.Set("Content-Type", contentType) + router.ServeHTTP(recorder, request) + return recorder +} + +func responseCode(t *testing.T, recorder *httptest.ResponseRecorder) apperror.Code { + t.Helper() + + var body struct { + Code apperror.Code `json:"code"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + return body.Code +} diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index 7357908d..25b3640c 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -5,7 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -44,37 +44,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject var ok bool _, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ") if !ok || accessToken == "" { - return "", false, "", time.Time{}, &common.NotSignedInError{} + return "", false, "", time.Time{}, apperror.NotSignedIn() } } token, err := m.jwtService.VerifyAccessToken(accessToken) if err != nil { - return "", false, "", time.Time{}, &common.NotSignedInError{} + return "", false, "", time.Time{}, apperror.NotSignedIn() } authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token) if err != nil { - return "", false, "", time.Time{}, &common.NotSignedInError{} + return "", false, "", time.Time{}, apperror.NotSignedIn() } authenticationTime, _ = token.IssuedAt() subject, ok := token.Subject() if !ok { - _ = c.Error(&common.TokenInvalidError{}) - return "", false, "", time.Time{}, &common.TokenInvalidError{} + _ = c.Error(apperror.TokenInvalid()) + return "", false, "", time.Time{}, apperror.TokenInvalid() } user, err := m.userService.GetUser(c, subject) if err != nil { - return "", false, "", time.Time{}, &common.NotSignedInError{} + return "", false, "", time.Time{}, apperror.NotSignedIn() } if user.Disabled { - return "", false, "", time.Time{}, &common.UserDisabledError{} + return "", false, "", time.Time{}, apperror.UserDisabled() } if adminRequired && !user.IsAdmin { - return "", false, "", time.Time{}, &common.MissingPermissionError{} + return "", false, "", time.Time{}, apperror.MissingPermission() } return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index 7530ff15..c6a07f86 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -3,16 +3,15 @@ package middleware import ( "context" "errors" + "fmt" "log/slog" - "math" "net" - "net/http" - "strconv" "time" "github.com/gin-gonic/gin" "github.com/italypaleale/francis/builtin/ratelimit" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" ) @@ -82,11 +81,12 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc { } } - // A missing service means the policy was never registered on the actor host, which is a development-time errror + // A missing service means the policy was never registered on the actor host, which is a development-time error svc := m.services[policy] if svc == nil { return func(c *gin.Context) { - c.AbortWithStatus(http.StatusInternalServerError) + _ = c.Error(apperror.Internal(fmt.Errorf("rate limiter service is not configured for policy %q", policy))) + c.Abort() } } @@ -113,11 +113,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc { } if !allowed { - // Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header - if retryAfter > 0 { - c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds())))) - } - _ = c.Error(&common.TooManyRequestsError{}) + // Advertise when the caller may retry, mapping the limiter's delay onto a structured application error + _ = c.Error(apperror.TooManyRequests().WithRetryAfter(retryAfter)) c.Abort() return } diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go index 42a567bc..040c2f5a 100644 --- a/backend/internal/middleware/rate_limit_test.go +++ b/backend/internal/middleware/rate_limit_test.go @@ -144,6 +144,9 @@ func TestRateLimitMiddleware(t *testing.T) { t.Run("fails with 500 when the policy is not registered", func(t *testing.T) { // An unknown policy has no bound service, which is a configuration error surfaced as a 500 r := newRateLimitRouter(t, services, "does-not-exist") - require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code) + w := doRateLimitRequest(t.Context(), r, "203.0.113.6") + require.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), `"code":"internal_error"`) + assert.NotContains(t, w.Body.String(), "rate limiter service is not configured") }) } diff --git a/backend/internal/oidc/authorization_handler.go b/backend/internal/oidc/authorization_handler.go index 4d11be49..e2d46efc 100644 --- a/backend/internal/oidc/authorization_handler.go +++ b/backend/internal/oidc/authorization_handler.go @@ -5,12 +5,13 @@ import ( "errors" "log/slog" "net/http" + "net/url" "strings" "time" "github.com/gin-gonic/gin" "github.com/ory/fosite" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -124,8 +125,8 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) { typedAuthenticationTime, _ := authenticationTime.(time.Time) var request completeInteractionRequest - if err := c.ShouldBindJSON(&request); err != nil { - _ = c.Error(&common.ValidationError{Message: "invalid interaction request"}) + if err := httpserver.BindJSON(c, &request); err != nil { + _ = c.Error(err) return } @@ -140,6 +141,12 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) { } func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) { + // Keep authorization policy denials in Pocket ID so users can see why access was refused + if errors.Is(err, fosite.ErrAccessDenied) { + h.redirectToInteractionError(c, err) + return + } + if ar.IsRedirectURIValid() { // Send the error to the client // fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path @@ -148,8 +155,10 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C return } - // If no redirect URI is available, we can't send the error to the client, - // so we redirect to a generic error page instead. + h.redirectToInteractionError(c, err) +} + +func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) { errorMessage := "An unknown error occurred during the authorization request." if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok { if err.HintField != "" { @@ -159,7 +168,9 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C } } - c.Redirect(http.StatusFound, "/interaction/error?error="+errorMessage) + query := url.Values{} + query.Set("error", errorMessage) + c.Redirect(http.StatusFound, "/interaction/error?"+query.Encode()) } func requestMetaFromGin(c *gin.Context) requestMeta { diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go index 026606dd..91be1bb2 100644 --- a/backend/internal/oidc/authorization_service.go +++ b/backend/internal/oidc/authorization_service.go @@ -12,7 +12,7 @@ import ( "time" "github.com/ory/fosite" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -119,7 +119,7 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp // Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil { - return authorizationResult{}, &common.OidcPARRequiredError{} + return authorizationResult{}, apperror.OidcPARRequired() } resource, err := input.requester.GetResource() @@ -209,6 +209,9 @@ func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req a Preload("UserGroups"). First(&user, "id = ?", req.userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return authorizationResult{}, apperror.UserNotFound() + } if err != nil { return authorizationResult{}, err } @@ -510,6 +513,9 @@ func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) { interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return interactionSessionForUser{}, apperror.OidcInteractionNotFound() + } if err != nil { return interactionSessionForUser{}, err } @@ -578,6 +584,9 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte err := withTx(ctx, s.db, func(ctx context.Context) error { var err error interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.OidcInteractionNotFound() + } if err != nil { return err } @@ -599,7 +608,7 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte } if requiredSteps[0] != step { - return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)} + return apperror.ValidationMessage("expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)) } if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil { @@ -646,7 +655,7 @@ func (s *authorizationService) applyInteractionStep(ctx context.Context, interac case interactionStepConsent: return s.completeConsentStep(ctx, interactionSession, userID, meta) default: - return &common.ValidationError{Message: "unknown interaction step " + string(step)} + return apperror.ValidationMessage("unknown interaction step " + string(step)) } } @@ -655,7 +664,7 @@ func (s *authorizationService) completeReauthenticationStep(ctx context.Context, return err } if reauthenticationToken == "" { - return &common.ValidationError{Message: "reauthentication token is required"} + return apperror.MissingField("reauthenticationToken") } reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) if err != nil { diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go index ddf0826c..02e76c53 100644 --- a/backend/internal/oidc/authorization_service_test.go +++ b/backend/internal/oidc/authorization_service_test.go @@ -9,7 +9,7 @@ import ( "github.com/ory/fosite" "github.com/stretchr/testify/require" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -317,6 +317,17 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) { require.ErrorIs(t, err, gorm.ErrRecordNotFound) } +func TestInteractionAPIClassifiesMissingSession(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil) + + _, err := service.getInteractionSession(t.Context(), "missing-interaction") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + + _, err = service.completeInteractionStep(t.Context(), "missing-interaction", "user", interactionStepConsent, "", time.Now().UTC(), requestMeta{}) + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) +} + func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) { db := testutils.NewDatabaseForTest(t) service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil) @@ -735,8 +746,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) { // Without a pushed authorization request the client must be rejected _, err := authorize("", false) - var parRequiredError *common.OidcPARRequiredError - require.ErrorAs(t, err, &parRequiredError) + require.True(t, apperror.IsCode(err, apperror.CodeOidcPARRequired)) // Re-entry after a completed interaction carries no request_uri, but the bound // interaction session proves the original request was PAR-validated diff --git a/backend/internal/oidc/device_handler.go b/backend/internal/oidc/device_handler.go index 38ac6620..9c56ba05 100644 --- a/backend/internal/oidc/device_handler.go +++ b/backend/internal/oidc/device_handler.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/ory/fosite" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -45,7 +45,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) { userCode := c.Query("code") if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) + _ = c.Error(apperror.MissingField("code")) return } @@ -73,7 +73,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) { func (h *deviceHandler) deviceCodeInfo(c *gin.Context) { userCode := c.Query("code") if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) + _ = c.Error(apperror.MissingField("code")) return } diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go index 43888fef..4f301e54 100644 --- a/backend/internal/oidc/device_service.go +++ b/backend/internal/oidc/device_service.go @@ -9,7 +9,7 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/rfc8628" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -93,12 +93,12 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, // logged-in user from rebinding a pending device authorization to themselves before the device // polls for its token. if request.GetUserCodeState() != fosite.UserCodeUnused { - return &common.OidcInvalidDeviceCodeError{} + return apperror.OidcInvalidDeviceCode() } client := request.GetClient().(Client) - var user model.User - if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil { + user, err := s.loadDeviceAuthorizationUser(ctx, userID) + if err != nil { return err } if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) { @@ -118,7 +118,7 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, return withTx(ctx, s.db, func(ctx context.Context) error { if client.RequiresReauthentication { if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil { - return &common.ReauthenticationRequiredError{} + return apperror.ReauthenticationRequired() } reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) @@ -164,6 +164,25 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, }) } +func (s *deviceService) loadDeviceAuthorizationUser(ctx context.Context, userID string) (model.User, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var user model.User + err := tx. + WithContext(ctx). + Preload("UserGroups"). + First(&user, "id = ?", userID). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, apperror.UserNotFound() + } + + return user, err +} + func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) { request, _, err := s.deviceRequestFromUserCode(ctx, userCode) if err != nil { @@ -232,7 +251,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature) if errors.Is(err, fosite.ErrNotFound) { - return nil, "", &common.OidcInvalidDeviceCodeError{} + return nil, "", apperror.OidcInvalidDeviceCode() } if err != nil { return nil, "", err @@ -240,7 +259,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil { if errors.Is(err, fosite.ErrDeviceExpiredToken) { - return nil, "", &common.OidcDeviceCodeExpiredError{} + return nil, "", apperror.OidcDeviceCodeExpired() } return nil, "", err } diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go index f93de187..15db3b53 100644 --- a/backend/internal/oidc/device_service_test.go +++ b/backend/internal/oidc/device_service_test.go @@ -13,13 +13,16 @@ import ( "time" "github.com/ory/fosite" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" "github.com/stretchr/testify/require" "gorm.io/gorm" ) +// #nosec G101 +const testReauthenticationToken = "valid-reauth-token" + type fakeReauthenticationConsumer struct { token string userID string @@ -30,7 +33,7 @@ type fakeReauthenticationConsumer struct { func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) { f.calls++ if token != f.token || userID != f.userID { - return time.Time{}, &common.ReauthenticationRequiredError{} + return time.Time{}, apperror.ReauthenticationRequired() } return f.reauthenticatedAt, nil @@ -41,15 +44,15 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t userID = "test-user" clientID = "test-client" ) - reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential - token: "valid-reauth-token", + reauth := &fakeReauthenticationConsumer{ + token: testReauthenticationToken, userID: userID, reauthenticatedAt: time.Now().UTC().Truncate(time.Second), } service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth) err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{}) - require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError)) + require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) require.Zero(t, reauth.calls) info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID) @@ -75,8 +78,8 @@ func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing. clientID = "test-client" ) reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second) - reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential - token: "valid-reauth-token", + reauth := &fakeReauthenticationConsumer{ + token: testReauthenticationToken, userID: userID, reauthenticatedAt: reauthenticatedAt, } @@ -153,7 +156,8 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe store := NewStore(db, apiAccess) signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), diff --git a/backend/internal/oidc/end_session_service.go b/backend/internal/oidc/end_session_service.go index 7dde1844..d63144bd 100644 --- a/backend/internal/oidc/end_session_service.go +++ b/backend/internal/oidc/end_session_service.go @@ -7,6 +7,7 @@ import ( "time" "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -34,35 +35,35 @@ func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL // client's post-logout callback URL (empty if none is configured). func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) { if input.IdTokenHint == "" { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } token, err := s.verifyIDTokenHint(input.IdTokenHint) if err != nil { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } clientIDs, ok := token.Audience() if !ok || len(clientIDs) == 0 { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } clientID := clientIDs[0] if input.ClientId != "" && clientID != input.ClientId { - return "", &common.OidcClientIdNotMatchingError{} + return "", apperror.OidcClientIDNotMatching() } subject, ok := token.Subject() if !ok || subject == "" { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } if userID != "" && subject != userID { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } userID = subject idTokenJTI, ok := token.JwtID() if !ok { - return "", &common.TokenInvalidError{} + return "", apperror.TokenInvalid() } var callbackURL string @@ -74,7 +75,7 @@ func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogout Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return &common.OidcMissingAuthorizationError{} + return apperror.OidcMissingAuthorization() } return err } @@ -117,7 +118,7 @@ func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, er // key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout. var tokenType string if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType { - return nil, &common.TokenInvalidError{} + return nil, apperror.TokenInvalid() } return token, nil @@ -133,7 +134,7 @@ func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL) if err != nil || matched == "" { - return "", &common.OidcInvalidCallbackURLError{} + return "", apperror.OidcInvalidCallbackURL() } return matched, nil diff --git a/backend/internal/oidc/end_session_service_test.go b/backend/internal/oidc/end_session_service_test.go index cdb8d664..60c6a942 100644 --- a/backend/internal/oidc/end_session_service_test.go +++ b/backend/internal/oidc/end_session_service_test.go @@ -13,6 +13,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/require" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -58,8 +59,7 @@ func TestLogoutCallbackURL(t *testing.T) { t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) { _, err := logoutCallbackURL(withURLs, "https://evil.example/steal") - var target *common.OidcInvalidCallbackURLError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL)) }) } @@ -144,15 +144,13 @@ func TestEndSessionService(t *testing.T) { t.Run("missing id_token_hint is rejected", func(t *testing.T) { service, _ := newService(t) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID) - var target *common.TokenInvalidError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken)) }) t.Run("malformed id_token_hint is rejected", func(t *testing.T) { service, _ := newService(t) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID) - var target *common.TokenInvalidError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken)) }) t.Run("token signed by a foreign key is rejected", func(t *testing.T) { @@ -166,8 +164,7 @@ func TestEndSessionService(t *testing.T) { require.NoError(t, err) _, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID) - var target *common.TokenInvalidError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken)) }) t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) { @@ -176,24 +173,21 @@ func TestEndSessionService(t *testing.T) { service, _ := newService(t) token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true}) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) - var target *common.TokenInvalidError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken)) }) t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) { service, _ := newService(t) token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti}) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID) - var target *common.TokenInvalidError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken)) }) t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) { service, _ := newService(t) token := signToken(t, validToken) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID) - var target *common.OidcClientIdNotMatchingError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeOidcClientIDNotMatching)) }) t.Run("user that never authorized the client is rejected", func(t *testing.T) { @@ -201,8 +195,7 @@ func TestEndSessionService(t *testing.T) { // A valid token for a user that has no authorization record for the client. token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti}) _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user") - var target *common.OidcMissingAuthorizationError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeOidcMissingAuthorization)) }) t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) { @@ -212,8 +205,7 @@ func TestEndSessionService(t *testing.T) { IdTokenHint: token, PostLogoutRedirectUri: "https://evil.example/steal", }, userID) - var target *common.OidcInvalidCallbackURLError - require.ErrorAs(t, err, &target) + require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL)) }) t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) { diff --git a/backend/internal/oidc/federated_client_auth.go b/backend/internal/oidc/federated_client_auth.go index 22d5e05b..6d5d10f8 100644 --- a/backend/internal/oidc/federated_client_auth.go +++ b/backend/internal/oidc/federated_client_auth.go @@ -19,7 +19,7 @@ import ( "github.com/ory/fosite" ) -const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec +const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" // #nosec G101 -- OAuth assertion type identifier, not a credential var errNoFederatedClientAssertion = errors.New("no federated client assertion") diff --git a/backend/internal/oidc/introspection_handler_test.go b/backend/internal/oidc/introspection_handler_test.go index abc8ac3c..1d3bd620 100644 --- a/backend/internal/oidc/introspection_handler_test.go +++ b/backend/internal/oidc/introspection_handler_test.go @@ -38,7 +38,8 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) { signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -142,7 +143,7 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"), diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go index c33b6823..1d48da5c 100644 --- a/backend/internal/oidc/preview_test.go +++ b/backend/internal/oidc/preview_test.go @@ -17,7 +17,8 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -66,7 +67,8 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) { signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go index 03f57996..942f61ec 100644 --- a/backend/internal/oidc/provider_test.go +++ b/backend/internal/oidc/provider_test.go @@ -58,7 +58,8 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) { signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -139,7 +140,8 @@ func TestProviderInsecureCallbackURLCompatibility(t *testing.T) { CallbackURLs: datatype.StringList{"http://client.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -176,7 +178,8 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) { CallbackURLs: datatype.StringList{"https://*.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -209,7 +212,8 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) { IsPublic: true, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -241,7 +245,8 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) { CallbackURLs: datatype.StringList{"https://*.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -281,7 +286,8 @@ func TestProviderAcceptsUnsignedRequestObject(t *testing.T) { CallbackURLs: datatype.StringList{"https://client.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -322,7 +328,8 @@ func TestProviderRejectsSignedRequestObject(t *testing.T) { CallbackURLs: datatype.StringList{"https://client.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -398,7 +405,8 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) { db := testutils.NewDatabaseForTest(t) require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error) - provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), @@ -468,7 +476,8 @@ func TestProviderIgnoresUnknownScopes(t *testing.T) { CallbackURLs: datatype.StringList{"https://app.example.com/callback"}, }).Error) - provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + // #nosec G101 + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", Secret: []byte("test-secret"), diff --git a/backend/internal/onetimeaccess/handler.go b/backend/internal/onetimeaccess/handler.go index 79e53bb4..54ecda78 100644 --- a/backend/internal/onetimeaccess/handler.go +++ b/backend/internal/onetimeaccess/handler.go @@ -7,8 +7,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -31,12 +32,11 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler { // @Param body body tokenCreateDto true "Token options" // @Success 201 {object} object "{ \"token\": \"string\" }" // @Router /api/users/{id}/one-time-access-token [post] -func (h *handler) createTokenForUser(c *gin.Context) { +func (h *handler) createTokenForUser(c *gin.Context) error { var input tokenCreateDto - err := c.ShouldBindJSON(&input) + err := httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } // Get the target user ID from the URL and apply the default expiration when no TTL is provided @@ -46,17 +46,16 @@ func (h *handler) createTokenForUser(c *gin.Context) { ttl = defaultTokenDuration } if userID == "" { - _ = c.Error(&common.UserIdNotProvidedError{}) - return + return apperror.MissingField("userId") } token, err := h.service.CreateToken(c.Request.Context(), userID, ttl) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, gin.H{"token": token}) + return nil } // requestEmailAsUnauthenticatedUser godoc @@ -68,28 +67,26 @@ func (h *handler) createTokenForUser(c *gin.Context) { // @Param body body emailAsUnauthenticatedUserDto true "Email request information" // @Success 204 "No Content" // @Router /api/one-time-access-email [post] -func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) { +func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input emailAsUnauthenticatedUserDto - err = dto.ShouldBindWithNormalizedJSON(c, &input) + err = httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } deviceToken, err := h.service.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), dbConfig, input.Email, input.RedirectPath) if err != nil { - _ = c.Error(err) - return + return err } cookie.AddDeviceTokenCookie(c, deviceToken) c.Status(http.StatusNoContent) + return nil } // requestEmailAsAdmin godoc @@ -102,18 +99,16 @@ func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) { // @Param body body emailAsAdminDto true "Email request options" // @Success 204 "No Content" // @Router /api/users/{id}/one-time-access-email [post] -func (h *handler) requestEmailAsAdmin(c *gin.Context) { +func (h *handler) requestEmailAsAdmin(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input emailAsAdminDto - err = c.ShouldBindJSON(&input) + err = httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } userID := c.Param("id") @@ -124,11 +119,11 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) { } err = h.service.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), dbConfig, userID, ttl) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // exchangeToken godoc @@ -138,36 +133,33 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) { // @Param token path string true "One-time access token" // @Success 200 {object} dto.UserDto // @Router /api/one-time-access-token/{token} [post] -func (h *handler) exchangeToken(c *gin.Context) { +func (h *handler) exchangeToken(c *gin.Context) error { cfg, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } loginCode := c.Param("token") // reject invalid length login codes if len(loginCode) != 6 && len(loginCode) != 16 { - _ = c.Error(&common.TokenInvalidOrExpiredError{}) - return + return apperror.TokenInvalidOrExpired() } deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) user, token, err := h.service.ExchangeToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent()) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto err = dto.MapStruct(user, &userDto) if err != nil { - _ = c.Error(err) - return + return err } maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) + return nil } diff --git a/backend/internal/onetimeaccess/module.go b/backend/internal/onetimeaccess/module.go index 2c352659..9f7a539e 100644 --- a/backend/internal/onetimeaccess/module.go +++ b/backend/internal/onetimeaccess/module.go @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -69,8 +70,8 @@ func New(deps Dependencies) (*Module, error) { // RegisterRoutes mounts the one-time access token endpoints // auth guards the admin routes, while the rate limiters throttle the public exchange and email endpoints func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, exchangeRateLimit, emailRateLimit gin.HandlerFunc) { - apiGroup.POST("/users/:id/one-time-access-token", auth, m.handler.createTokenForUser) - apiGroup.POST("/users/:id/one-time-access-email", auth, m.handler.requestEmailAsAdmin) - apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, m.handler.exchangeToken) - apiGroup.POST("/one-time-access-email", emailRateLimit, m.handler.requestEmailAsUnauthenticatedUser) + apiGroup.POST("/users/:id/one-time-access-token", auth, httpserver.Handle(m.handler.createTokenForUser)) + apiGroup.POST("/users/:id/one-time-access-email", auth, httpserver.Handle(m.handler.requestEmailAsAdmin)) + apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, httpserver.Handle(m.handler.exchangeToken)) + apiGroup.POST("/one-time-access-email", emailRateLimit, httpserver.Handle(m.handler.requestEmailAsUnauthenticatedUser)) } diff --git a/backend/internal/onetimeaccess/service.go b/backend/internal/onetimeaccess/service.go index 8ac91060..06510433 100644 --- a/backend/internal/onetimeaccess/service.go +++ b/backend/internal/onetimeaccess/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -50,7 +51,7 @@ func newService(deps Dependencies, actorService *actor.Service) *Service { func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error { if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() { - return &common.OneTimeAccessDisabledError{} + return apperror.OneTimeAccessDisabled() } _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig) @@ -59,7 +60,7 @@ func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig func (s *Service) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) { if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() { - return "", &common.OneTimeAccessDisabledError{} + return "", apperror.OneTimeAccessDisabled() } var userId string @@ -89,7 +90,7 @@ func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID, } if user.Email == nil { - return nil, &common.UserEmailNotSetError{} + return nil, apperror.UserEmailNotSet() } oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken) @@ -134,7 +135,7 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat // Load the user to ensure it exists _, err = s.userProvider.GetUser(ctx, userID) if errors.Is(err, gorm.ErrRecordNotFound) { - return "", &common.UserNotFoundError{} + return "", apperror.UserNotFound() } else if err != nil { return "", err } @@ -167,9 +168,9 @@ func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConf switch consumeRes.Status { case tokenConsumeNotFound: - return model.User{}, "", &common.TokenInvalidOrExpiredError{} + return model.User{}, "", apperror.TokenInvalidOrExpired() case tokenConsumeDeviceMismatch: - return model.User{}, "", &common.DeviceCodeInvalid{} + return model.User{}, "", apperror.DeviceCodeInvalid() case tokenConsumeOK: // All good, continue below default: @@ -195,13 +196,13 @@ func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig First(&user). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return model.User{}, "", &common.TokenInvalidOrExpiredError{} + return model.User{}, "", apperror.TokenInvalidOrExpired() } else if err != nil { return model.User{}, "", err } if user.Disabled { - return model.User{}, "", &common.UserDisabledError{} + return model.User{}, "", apperror.UserDisabled() } accessToken, err := s.signer.GenerateAccessToken( diff --git a/backend/internal/onetimeaccess/service_test.go b/backend/internal/onetimeaccess/service_test.go index 2ef6c7dd..dde0d20d 100644 --- a/backend/internal/onetimeaccess/service_test.go +++ b/backend/internal/onetimeaccess/service_test.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -128,8 +128,7 @@ func TestExchangeTokenInvalidToken(t *testing.T) { dbConfig := appconfig.NewTestConfig(nil) _, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "") - var invalidErr *common.TokenInvalidOrExpiredError - require.ErrorAs(t, err, &invalidErr) + require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired)) } func TestExchangeTokenDeviceMismatch(t *testing.T) { @@ -150,8 +149,7 @@ func TestExchangeTokenDeviceMismatch(t *testing.T) { dbConfig := appconfig.NewTestConfig(nil) _, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "") - var deviceErr *common.DeviceCodeInvalid - require.ErrorAs(t, err, &deviceErr) + require.True(t, apperror.IsCode(err, apperror.CodeDeviceCodeInvalid)) // The token must not have been consumed on a device-token mismatch var state TokenState @@ -178,8 +176,7 @@ func TestExchangeTokenRejectsDisabledUser(t *testing.T) { dbConfig := appconfig.NewTestConfig(nil) exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "") - var userDisabledErr *common.UserDisabledError - require.ErrorAs(t, err, &userDisabledErr) + require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled)) require.Empty(t, exchangedUser.ID) require.Empty(t, accessToken) diff --git a/backend/internal/service/app_images_service.go b/backend/internal/service/app_images_service.go index 4f9ea447..c7df9b95 100644 --- a/backend/internal/service/app_images_service.go +++ b/backend/internal/service/app_images_service.go @@ -9,7 +9,7 @@ import ( "strings" "sync" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image" @@ -40,7 +40,7 @@ func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCl reader, size, err := s.storage.Open(ctx, imagePath) if err != nil { if storage.IsNotExist(err) { - return nil, 0, "", &common.ImageNotFoundError{} + return nil, 0, "", apperror.ImageNotFound() } return nil, 0, "", err } @@ -51,7 +51,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) mimeType := utils.GetImageMimeType(fileType) if mimeType == "" { - return &common.FileTypeNotSupportedError{} + return apperror.UnsupportedFileType("") } s.mu.Lock() @@ -96,7 +96,7 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er ext, ok := s.extensions[imageName] if !ok || ext == "" { - return &common.ImageNotFoundError{} + return apperror.ImageNotFound() } imagePath := path.Join("application-images", imageName+"."+ext) @@ -122,7 +122,7 @@ func (s *AppImagesService) getExtension(name string) (string, error) { ext, ok := s.extensions[name] if !ok || ext == "" { - return "", &common.ImageNotFoundError{} + return "", apperror.ImageNotFound() } return strings.ToLower(ext), nil diff --git a/backend/internal/service/app_images_service_test.go b/backend/internal/service/app_images_service_test.go index 3264b538..96cfa4bf 100644 --- a/backend/internal/service/app_images_service_test.go +++ b/backend/internal/service/app_images_service_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/storage" ) @@ -85,15 +85,13 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) { t.Run("get missing image returns not found", func(t *testing.T) { _, _, _, err := service.GetImage(context.Background(), "missing") require.Error(t, err) - var imageErr *common.ImageNotFoundError - assert.ErrorAs(t, err, &imageErr) + assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound)) }) t.Run("reject unsupported file types", func(t *testing.T) { err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo") require.Error(t, err) - var fileTypeErr *common.FileTypeNotSupportedError - assert.ErrorAs(t, err, &fileTypeErr) + assert.True(t, apperror.IsCode(err, apperror.CodeFileTypeNotSupported)) }) t.Run("delete and extension tracking", func(t *testing.T) { @@ -105,8 +103,7 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) { err := service.DeleteImage(context.Background(), "default-profile-picture") require.Error(t, err) - var imageErr *common.ImageNotFoundError - assert.ErrorAs(t, err, &imageErr) + assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound)) }) } diff --git a/backend/internal/service/custom_claim_service.go b/backend/internal/service/custom_claim_service.go index aec9bc8b..e0c8dcec 100644 --- a/backend/internal/service/custom_claim_service.go +++ b/backend/internal/service/custom_claim_service.go @@ -2,8 +2,10 @@ package service import ( "context" + "errors" + "fmt" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "gorm.io/gorm" @@ -63,6 +65,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, user tx.Rollback() }() + // Reject missing owners before replacing claims so invalid IDs cannot appear successful + if err := ensureCustomClaimOwnerExists(ctx, tx, UserID, userID); err != nil { + return nil, err + } + updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx) if err != nil { return nil, err @@ -83,6 +90,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, tx.Rollback() }() + // Reject missing owners before replacing claims so invalid IDs cannot appear successful + if err := ensureCustomClaimOwnerExists(ctx, tx, UserGroupID, userGroupID); err != nil { + return nil, err + } + updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx) if err != nil { return nil, err @@ -96,13 +108,42 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, return updatedClaims, nil } -// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction +func ensureCustomClaimOwnerExists(ctx context.Context, tx *gorm.DB, ownerType idType, ownerID string) error { + // Select the owner model and corresponding client-safe not-found error + var ( + target any + notFound error + ) + switch ownerType { + case UserID: + target = &model.User{} + notFound = apperror.UserNotFound() + case UserGroupID: + target = &model.UserGroup{} + notFound = apperror.NotFound("User group") + default: + return fmt.Errorf("unsupported custom claim owner type %q", ownerType) + } + + // Verify the owner in the active transaction before changing its claims + err := tx.WithContext(ctx). + Select("id"). + First(target, "id = ?", ownerID). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound + } + + return err +} + +// updateCustomClaimsInternal keeps claim replacements within the caller's transaction func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) { - // Check for duplicate keys in the claims slice + // Reject duplicate keys before changing persisted claims seenKeys := make(map[string]struct{}) for _, claim := range claims { if _, ok := seenKeys[claim.Key]; ok { - return nil, &common.DuplicateClaimError{Key: claim.Key} + return nil, apperror.DuplicateClaim(claim.Key) } seenKeys[claim.Key] = struct{}{} } @@ -117,7 +158,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT return nil, err } - // Delete claims that are not in the new list + // Remove stale claims before applying the requested replacement set for _, existingClaim := range existingClaims { found := false for _, claim := range claims { @@ -138,10 +179,10 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT } } - // Add or update claims + // Reject reserved keys and persist each requested claim for _, claim := range claims { if isReservedClaim(claim.Key) { - return nil, &common.ReservedClaimError{Key: claim.Key} + return nil, apperror.ReservedClaim(claim.Key) } customClaim := model.CustomClaim{ Key: claim.Key, @@ -155,7 +196,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT customClaim.UserGroupID = &value } - // Update the claim if it already exists or create a new one + // Preserve claim identity when updating an existing owner and key pair err = tx. WithContext(ctx). Where(string(idType)+" = ? AND key = ?", value, claim.Key). @@ -167,7 +208,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT } } - // Get the updated claims + // Return the persisted replacement set to the caller var updatedClaims []model.CustomClaim err = tx. WithContext(ctx). diff --git a/backend/internal/service/custom_claim_service_test.go b/backend/internal/service/custom_claim_service_test.go new file mode 100644 index 00000000..a8510601 --- /dev/null +++ b/backend/internal/service/custom_claim_service_test.go @@ -0,0 +1,19 @@ +package service + +import ( + "testing" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/require" +) + +func TestCustomClaimUpdatesRejectMissingOwner(t *testing.T) { + service := NewCustomClaimService(testutils.NewDatabaseForTest(t)) + + _, err := service.UpdateCustomClaimsForUser(t.Context(), "missing-user", nil) + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) + + _, err = service.UpdateCustomClaimsForUserGroup(t.Context(), "missing-group", nil) + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) +} diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 34f6a3f4..b874b30e 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "encoding/base64" - "errors" "fmt" "io" "log/slog" @@ -24,7 +23,7 @@ import ( "golang.org/x/text/unicode/norm" "gorm.io/gorm" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -84,7 +83,7 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserServi func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) { if !dbConfig.LdapEnabled.IsTrue() { - return nil, fmt.Errorf("LDAP is not enabled") + return nil, apperror.LdapDisabled() } // Setup LDAP connection @@ -522,7 +521,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs userID := databaseUser.ID if databaseUser.ID == "" { createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig) - if errors.Is(err, &common.AlreadyInUseError{}) { + if apperror.IsCode(err, apperror.CodeAlreadyInUse) { slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) continue } else if err != nil { @@ -533,7 +532,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs ldapUsersByID[desiredUser.ldapID] = createdUser } else { _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig) - if errors.Is(err, &common.AlreadyInUseError{}) { + if apperror.IsCode(err, apperror.CodeAlreadyInUse) { slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) continue } else if err != nil { @@ -573,7 +572,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig) if err != nil { - if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok { + if apperror.IsCode(err, apperror.CodeLdapUserUpdate) { return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username) } return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err) diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index d29fa799..5218d3e8 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -19,6 +20,14 @@ type fakeLDAPClient struct { searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) } +func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) { + service := NewLdapService(nil, nil, nil, nil, nil) + + _, err := service.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"}) + + require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled)) +} + func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { if c.searchFn == nil { return nil, nil diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index f5979a0f..3c8eb6c1 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -85,14 +85,14 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid // for a CIMD client, bypassing the cache TTL, and returns the refreshed client. func (s *OidcService) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) { if s.metadataRefresher == nil { - return model.OidcClient{}, &common.ValidationError{Message: "client ID metadata documents are not enabled"} + return model.OidcClient{}, apperror.ValidationMessage("Client ID metadata documents are not enabled") } client, err := s.metadataRefresher.RefreshClientMetadata(ctx, clientID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return model.OidcClient{}, err } - return model.OidcClient{}, &common.ValidationError{Message: err.Error()} + return model.OidcClient{}, apperror.ValidationMessage(err.Error()) } return client, nil } @@ -107,6 +107,9 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } q = q.First(&client, "id = ?", clientID) + if errors.Is(q.Error, gorm.ErrRecordNotFound) { + return model.OidcClient{}, apperror.NotFound("OIDC client") + } if q.Error != nil { return model.OidcClient{}, q.Error } @@ -155,7 +158,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.OidcClient{}, &common.ClientIdAlreadyExistsError{} + return model.OidcClient{}, apperror.ClientIDAlreadyExists() } return model.OidcClient{}, err } @@ -284,14 +287,16 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error { var client model.OidcClient - err := s.db. + result := s.db. WithContext(ctx). Where("id = ?", clientID). Clauses(clause.Returning{}). - Delete(&client). - Error - if err != nil { - return err + Delete(&client) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return apperror.NotFound("OIDC client") } // Delete images if present @@ -314,17 +319,13 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i tx.Rollback() }() - var client model.OidcClient - err := tx. - WithContext(ctx). - First(&client, "id = ?", clientID). - Error + client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return "", err } if client.IsPublic { - return "", &common.ValidationError{Message: "cannot create a secret for a public client"} + return "", apperror.ValidationMessage("Cannot create a secret for a public client") } clientSecret := input.Secret @@ -358,11 +359,7 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i } func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) { - var client model.OidcClient - err := s.db. - WithContext(ctx). - First(&client, "id = ?", clientID). - Error + client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { return nil, 0, "", err } @@ -378,7 +375,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light // Light logo if requested or no dark logo is available ext = *client.ImageType default: - return nil, 0, "", errors.New("image not found") + return nil, 0, "", apperror.ImageNotFound() } mimeType := utils.GetImageMimeType(ext) @@ -388,6 +385,9 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light key := oidcClientImagePath(client.ID, suffix, ext) reader, size, err := s.fileStorage.Open(ctx, key) if err != nil { + if storage.IsNotExist(err) { + return nil, 0, "", apperror.ImageNotFound() + } return nil, 0, "", err } @@ -397,7 +397,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error { fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { - return &common.FileTypeNotSupportedError{} + return apperror.UnsupportedFileType("") } var darkSuffix string @@ -432,7 +432,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error { return s.deleteClientLogoInternal(ctx, clientID, "", func(client *model.OidcClient) (string, error) { if client.ImageType == nil { - return "", errors.New("image not found") + return "", apperror.ImageNotFound() } oldImageType := *client.ImageType @@ -444,7 +444,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error { return s.deleteClientLogoInternal(ctx, clientID, "-dark", func(client *model.OidcClient) (string, error) { if client.DarkImageType == nil { - return "", errors.New("image not found") + return "", apperror.ImageNotFound() } oldImageType := *client.DarkImageType @@ -459,11 +459,7 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str tx.Rollback() }() - var client model.OidcClient - err := tx. - WithContext(ctx). - First(&client, "id = ?", clientID). - Error + client, err := s.getClientInternal(ctx, clientID, tx, true) if err != nil { return err } @@ -555,8 +551,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri tx.Rollback() }() - var client model.OidcClient - err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error + client, err := s.getClientInternal(ctx, id, tx, false) if err != nil { return 0, err } @@ -566,8 +561,25 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri } func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() - query := s.db. + var user model.User + err := tx. + WithContext(ctx). + Select("id"). + First(&user, "id = ?", userID). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.PaginationResponse{}, apperror.UserNotFound() + } + if err != nil { + return nil, utils.PaginationResponse{}, err + } + + query := tx. WithContext(ctx). Model(&model.UserAuthorizedOidcClient{}). Preload("Client"). @@ -590,6 +602,9 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, WithContext(ctx). Where("user_id = ? AND client_id = ?", userID, clientID). First(&authorizedClient).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("Client authorization") + } if err != nil { return err } @@ -623,6 +638,9 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri Preload("UserGroups"). First(&user, "id = ?", userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.PaginationResponse{}, apperror.UserNotFound() + } if err != nil { return nil, utils.PaginationResponse{}, err } @@ -692,12 +710,15 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use Preload("UserGroups"). First(&user, "id = ?", userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperror.UserNotFound() + } if err != nil { return nil, err } if !oidc.IsUserGroupAllowedToAuthorize(user, client) { - return nil, &common.OidcAccessDeniedError{} + return nil, apperror.OidcAccessDenied() } preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod) @@ -711,8 +732,6 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use }, nil } -var errLogoTooLarge = errors.New("logo is too large") - func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client { if source == nil { source = http.DefaultClient @@ -732,7 +751,10 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error { u, err := url.Parse(raw) if err != nil { - return err + return apperror.InvalidLogoURL(err) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return apperror.InvalidLogoURL(fmt.Errorf("URL must use HTTP or HTTPS and include a host")) } ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second) @@ -741,22 +763,22 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie // Prevents SSRF by allowing only public IPs ok, err := utils.IsURLPrivate(ctx, u) if err != nil { - return err + return apperror.LogoDownloadFailed(err) } else if ok { - return errors.New("private IP addresses are not allowed") + return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) } // We need to check this on redirects too client := httpClientWithCheckRedirect(s.httpClient, func(r *http.Request, via []*http.Request) error { if len(via) >= 10 { - return errors.New("stopped after 10 redirects") + return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects")) } ok, err := utils.IsURLPrivate(r.Context(), r.URL) if err != nil { return err } else if ok { - return errors.New("private IP addresses are not allowed") + return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) } return nil @@ -764,24 +786,27 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) if err != nil { - return err + return apperror.InvalidLogoURL(err) } req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher") req.Header.Set("Accept", "image/*") resp, err := client.Do(req) if err != nil { - return err + if appErr, ok := errors.AsType[*apperror.Error](err); ok { + return appErr + } + return apperror.LogoDownloadFailed(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch logo: %s", resp.Status) + return apperror.LogoDownloadFailed(fmt.Errorf("logo server returned %s", resp.Status)) } const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB if resp.ContentLength > maxLogoSize { - return errLogoTooLarge + return apperror.LogoTooLarge("2 MB") } // Prefer extension in path if supported @@ -792,7 +817,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie } if ext == "" { - return &common.FileTypeNotSupportedError{} + return apperror.LogoTypeNotSupported() } var darkSuffix string @@ -803,17 +828,17 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie limitReader := utils.NewLimitReader(resp.Body, maxLogoSize+1) strippedReader, err := imageutil.StripMetadata(limitReader, ext) if errors.Is(err, utils.ErrSizeExceeded) { - return errLogoTooLarge + return apperror.LogoTooLarge("2 MB") } else if err != nil { - return err + return apperror.LogoDownloadFailed(err) } imagePath := oidcClientImagePath(clientID, darkSuffix, ext) err = s.fileStorage.Save(ctx, imagePath, strippedReader) if errors.Is(err, utils.ErrSizeExceeded) { - return errLogoTooLarge + return apperror.LogoTooLarge("2 MB") } else if err != nil { - return err + return apperror.LogoDownloadFailed(err) } err = s.updateClientLogoType(ctx, clientID, ext, light) @@ -843,6 +868,9 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string, First(&client, "id = ?", clientID). Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("OIDC client") + } return fmt.Errorf("failed to look up client: %w", err) } @@ -892,6 +920,9 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID First(&provider, "oidc_client_id = ?", clientID). Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider") + } return model.ScimServiceProvider{}, err } diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index fb7bcf77..6c9cc816 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -22,6 +22,14 @@ import ( testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) +func TestListAuthorizedClientsRejectsMissingUser(t *testing.T) { + service := &OidcService{db: testutils.NewDatabaseForTest(t)} + + _, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", utils.ListRequestOptions{}) + + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) +} + func TestOidcService_DeleteClientDeletesOAuth2Sessions(t *testing.T) { db := testutils.NewDatabaseForTest(t) require.NoError(t, db.Exec("PRAGMA foreign_keys = ON").Error) @@ -193,7 +201,7 @@ func TestOidcService_updateClientLogoType(t *testing.T) { t.Run("Returns error for non-existent client", func(t *testing.T) { err := s.updateClientLogoType(t.Context(), "non-existent-client-id", "png", true) require.Error(t, err) - require.ErrorContains(t, err, "failed to look up client") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) }) } @@ -403,6 +411,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "://invalid-url", true) require.Error(t, err) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) }) t.Run("Returns error for non-200 status code", func(t *testing.T) { @@ -424,7 +433,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true) require.Error(t, err) - require.ErrorContains(t, err, "failed to fetch logo") + require.True(t, apperror.IsCode(err, apperror.CodeLogoDownloadFailed)) }) t.Run("Returns error for too large content", func(t *testing.T) { @@ -454,7 +463,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true) require.Error(t, err) - require.ErrorIs(t, err, errLogoTooLarge) + require.True(t, apperror.IsCode(err, apperror.CodeLogoTooLarge)) }) t.Run("Returns error for unsupported file type", func(t *testing.T) { @@ -480,8 +489,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true) require.Error(t, err) - var fileTypeErr *common.FileTypeNotSupportedError - require.ErrorAs(t, err, &fileTypeErr) + require.True(t, apperror.IsCode(err, apperror.CodeLogoTypeNotSupported)) }) t.Run("Returns error for non-existent client", func(t *testing.T) { @@ -507,7 +515,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true) require.Error(t, err) - require.ErrorContains(t, err, "failed to look up client") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) }) } diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go index b4970165..20b04689 100644 --- a/backend/internal/service/scim_service.go +++ b/backend/internal/service/scim_service.go @@ -17,6 +17,7 @@ import ( "time" "github.com/go-co-op/gocron/v2" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -73,6 +74,9 @@ func (s *ScimService) GetServiceProvider( Preload("OidcClient.AllowedUserGroups"). First(&provider, "id = ?", serviceProviderID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider") + } if err != nil { return model.ScimServiceProvider{}, err } @@ -94,13 +98,25 @@ func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimSer func (s *ScimService) CreateServiceProvider( ctx context.Context, input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil { + return model.ScimServiceProvider{}, err + } + provider := model.ScimServiceProvider{ Endpoint: input.Endpoint, Token: datatype.EncryptedString(input.Token), OidcClientID: input.OidcClientID, } - if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil { + if err := tx.WithContext(ctx).Create(&provider).Error; err != nil { + return model.ScimServiceProvider{}, err + } + if err := tx.Commit().Error; err != nil { return model.ScimServiceProvider{}, err } @@ -111,29 +127,64 @@ func (s *ScimService) UpdateServiceProvider(ctx context.Context, serviceProviderID string, input *dto.ScimServiceProviderCreateDTO, ) (model.ScimServiceProvider, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + var provider model.ScimServiceProvider - err := s.db.WithContext(ctx). + err := tx.WithContext(ctx). First(&provider, "id = ?", serviceProviderID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider") + } if err != nil { return model.ScimServiceProvider{}, err } + if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil { + return model.ScimServiceProvider{}, err + } + provider.Endpoint = input.Endpoint provider.Token = datatype.EncryptedString(input.Token) provider.OidcClientID = input.OidcClientID - if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil { + if err := tx.WithContext(ctx).Save(&provider).Error; err != nil { + return model.ScimServiceProvider{}, err + } + if err := tx.Commit().Error; err != nil { return model.ScimServiceProvider{}, err } return provider, nil } -func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error { - return s.db.WithContext(ctx). - Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID). +func ensureScimOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error { + var client model.OidcClient + err := db.WithContext(ctx). + Select("id"). + First(&client, "id = ?", clientID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("OIDC client") + } + + return err +} + +func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error { + result := s.db.WithContext(ctx). + Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return apperror.NotFound("SCIM service provider") + } + + return nil } //nolint:contextcheck diff --git a/backend/internal/service/scim_service_test.go b/backend/internal/service/scim_service_test.go new file mode 100644 index 00000000..5deba203 --- /dev/null +++ b/backend/internal/service/scim_service_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "testing" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/require" +) + +func TestScimServiceProviderOperationsReturnSpecificNotFoundErrors(t *testing.T) { + service := NewScimService(testutils.NewDatabaseForTest(t), nil, nil) + + _, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{ + Endpoint: "https://scim.example.com", + OidcClientID: "missing-client", + }) + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + + _, err = service.GetServiceProvider(t.Context(), "missing-provider") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + + err = service.DeleteServiceProvider(t.Context(), "missing-provider") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) +} + +func TestScimServiceProviderCreateAndUpdate(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := NewScimService(db, nil, nil) + + // Create two clients so provider creation and reassignment both satisfy the foreign key + require.NoError(t, db.Create(&[]model.OidcClient{ + {Base: model.Base{ID: "client-1"}, Name: "Client 1"}, + {Base: model.Base{ID: "client-2"}, Name: "Client 2"}, + }).Error) + + // Create the provider with its initial client in one transaction + provider, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{ + Endpoint: "https://scim.example.com/v1", + OidcClientID: "client-1", + }) + require.NoError(t, err) + require.NotEmpty(t, provider.ID) + + // Move the provider to the second client in one transaction + provider, err = service.UpdateServiceProvider(t.Context(), provider.ID, &dto.ScimServiceProviderCreateDTO{ + Endpoint: "https://scim.example.com/v2", + OidcClientID: "client-2", + }) + require.NoError(t, err) + require.Equal(t, "https://scim.example.com/v2", provider.Endpoint) + require.Equal(t, "client-2", provider.OidcClientID) + + // Verify the committed provider retains both updated values + persisted, err := service.GetServiceProvider(t.Context(), provider.ID) + require.NoError(t, err) + require.Equal(t, provider.Endpoint, persisted.Endpoint) + require.Equal(t, provider.OidcClientID, persisted.OidcClientID) +} diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index fe942250..ecb4937d 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -9,7 +9,7 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -59,6 +59,9 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm. Preload("AllowedOidcClients"). First(&group). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.UserGroup{}, apperror.NotFound("User group") + } return group, err } @@ -74,13 +77,16 @@ func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigM Where("id = ?", id). First(&group). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("User group") + } if err != nil { return err } // Disallow deleting the group if it is an LDAP group and LDAP is enabled if group.LdapID != nil && cfg.LdapEnabled.IsTrue() { - return &common.LdapUserGroupUpdateError{} + return apperror.LdapUserGroupUpdate() } err = tx. @@ -123,7 +129,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro Create(&group). Error if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} + return model.UserGroup{}, apperror.AlreadyInUse("name") } else if err != nil { return model.UserGroup{}, err } @@ -163,7 +169,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input // Disallow updating the group if it is an LDAP group and LDAP is enabled if !isLdapSync && group.LdapID != nil { if cfg.LdapEnabled.IsTrue() { - return model.UserGroup{}, &common.LdapUserGroupUpdateError{} + return model.UserGroup{}, apperror.LdapUserGroupUpdate() } } @@ -177,7 +183,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input Save(&group). Error if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} + return model.UserGroup{}, apperror.AlreadyInUse("name") } else if err != nil { return model.UserGroup{}, err } @@ -269,6 +275,9 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) ( Where("id = ?", id). First(&group). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, apperror.NotFound("User group") + } if err != nil { return 0, err } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 2b4674e0..0344383f 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -17,7 +17,7 @@ import ( "gorm.io/gorm/clause" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -80,13 +80,16 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go Where("id = ?", userID). First(&user). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, apperror.UserNotFound() + } return user, err } func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) { // Validate the user ID to prevent directory traversal if err := uuid.Validate(userID); err != nil { - return nil, 0, &common.InvalidUUIDError{} + return nil, 0, apperror.InvalidUserID() } user, err := s.GetUser(ctx, userID) @@ -110,7 +113,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io. if err == nil { return reader, size, nil } - if !errors.Is(err, &common.ImageNotFoundError{}) { + if !apperror.IsCode(err, apperror.CodeImageNotFound) { return nil, 0, err } } @@ -153,6 +156,9 @@ func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model Where("id = ?", userID). First(&user). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperror.UserNotFound() + } if err != nil { return nil, err } @@ -163,11 +169,18 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f // Validate the user ID to prevent directory traversal err := uuid.Validate(userID) if err != nil { - return &common.InvalidUUIDError{} + return apperror.InvalidUserID() + } + + if _, err := s.GetUser(ctx, userID); err != nil { + return err } // Convert the image to a smaller square image profilePicture, err := profilepicture.CreateProfilePicture(file) + if errors.Is(err, profilepicture.ErrInvalidImage) { + return apperror.InvalidImage(err) + } if err != nil { return err } @@ -207,6 +220,9 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI Clauses(clause.Locking{Strength: "UPDATE"}). First(&user). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.UserNotFound() + } if err != nil { return fmt.Errorf("failed to load user to delete: %w", err) } @@ -214,7 +230,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled if !allowLdapDelete && !user.Disabled && user.LdapID != nil { if cfg.LdapEnabled.IsTrue() { - return &common.LdapUserUpdateError{} + return apperror.LdapUserUpdate() } } @@ -255,7 +271,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfi func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) { if cfg.RequireUserEmail.IsTrue() && input.Email == nil { - return model.User{}, &common.UserEmailNotSetError{} + return model.User{}, apperror.MissingField("email") } var userGroups []model.UserGroup @@ -438,7 +454,7 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) { if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil { - return model.User{}, &common.UserEmailNotSetError{} + return model.User{}, apperror.MissingField("email") } var user model.User @@ -448,6 +464,9 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd Clauses(clause.Locking{Strength: "UPDATE"}). First(&user). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, apperror.UserNotFound() + } if err != nil { return model.User{}, err } @@ -588,7 +607,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User return err } if result.Found { - return &common.AlreadyInUseError{Property: "email"} + return apperror.AlreadyInUse("email") } err = tx. @@ -600,7 +619,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User return err } if result.Found { - return &common.AlreadyInUseError{Property: "username"} + return apperror.AlreadyInUse("username") } return nil @@ -610,7 +629,11 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) error { // Validate the user ID to prevent directory traversal if err := uuid.Validate(userID); err != nil { - return &common.InvalidUUIDError{} + return apperror.InvalidUserID() + } + + if _, err := s.GetUser(ctx, userID); err != nil { + return err } profilePicturePath := path.Join("profile-pictures", userID+".png") diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 541e7a40..785a29a7 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -2,11 +2,14 @@ package service import ( "encoding/json" + "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -34,6 +37,48 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) { return userService, groupService } +func TestUserAndGroupLookupsReturnSpecificNotFoundErrors(t *testing.T) { + userService, groupService := newTestUserService(t) + + _, err := userService.GetUser(t.Context(), "missing-user") + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) + + _, err = groupService.Get(t.Context(), "missing-group") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + var appErr *apperror.Error + require.ErrorAs(t, err, &appErr) + require.Equal(t, "User group", appErr.Details()["resource"]) +} + +func TestUpdateProfilePictureRejectsInvalidImageData(t *testing.T) { + userService, _ := newTestUserService(t) + config := &appconfig.AppConfigModel{RequireUserEmail: "false"} + user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{ + ID: uuid.NewString(), + Username: "image-test", + }) + require.NoError(t, err) + + err = userService.UpdateProfilePicture( + t.Context(), + user.ID, + strings.NewReader("not an image"), + ) + + require.True(t, apperror.IsCode(err, apperror.CodeInvalidImage)) +} + +func TestProfilePictureUpdatesRejectMissingUser(t *testing.T) { + userService, _ := newTestUserService(t) + missingUserID := uuid.NewString() + + err := userService.UpdateProfilePicture(t.Context(), missingUserID, strings.NewReader("not an image")) + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) + + err = userService.ResetProfilePicture(t.Context(), missingUserID) + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) +} + func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) { config := &appconfig.AppConfigModel{RequireUserEmail: "false"} userService, groupService := newTestUserService(t) diff --git a/backend/internal/usersignup/handler.go b/backend/internal/usersignup/handler.go index ec1552c2..eab0e9c5 100644 --- a/backend/internal/usersignup/handler.go +++ b/backend/internal/usersignup/handler.go @@ -7,8 +7,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -24,19 +25,18 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler { return &handler{service: service, appConfig: appConfig} } -func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) { +func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) error { setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } if setupCompleted { - _ = c.Error(&common.SetupNotAvailableError{}) - return + return apperror.SetupNotAvailable() } c.Status(http.StatusNoContent) + return nil } // signUpInitialAdmin godoc @@ -48,37 +48,34 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) { // @Param body body signUpDto true "User information" // @Success 200 {object} dto.UserDto // @Router /api/signup/setup [post] -func (h *handler) signUpInitialAdmin(c *gin.Context) { +func (h *handler) signUpInitialAdmin(c *gin.Context) error { config, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input signUpDto - err = dto.ShouldBindWithNormalizedJSON(c, &input) + err = httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto err = dto.MapStruct(user, &userDto) if err != nil { - _ = c.Error(err) - return + return err } maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) + return nil } // createSignupTokenHandler godoc @@ -90,11 +87,10 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) { // @Param token body signupTokenCreateDto true "Signup token information" // @Success 201 {object} signupTokenDto // @Router /api/signup-tokens [post] -func (h *handler) createSignupToken(c *gin.Context) { +func (h *handler) createSignupToken(c *gin.Context) error { var input signupTokenCreateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } ttl := input.TTL.Duration @@ -104,18 +100,17 @@ func (h *handler) createSignupToken(c *gin.Context) { signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) if err != nil { - _ = c.Error(err) - return + return err } var tokenDto signupTokenDto err = dto.MapStruct(signupToken, &tokenDto) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, tokenDto) + return nil } // listSignupTokensHandler godoc @@ -128,26 +123,25 @@ func (h *handler) createSignupToken(c *gin.Context) { // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Success 200 {object} dto.Paginated[signupTokenDto] // @Router /api/signup-tokens [get] -func (h *handler) listSignupTokens(c *gin.Context) { +func (h *handler) listSignupTokens(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions) if err != nil { - _ = c.Error(err) - return + return err } var tokensDto []signupTokenDto err = dto.MapStructList(tokens, &tokensDto) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{ Data: tokensDto, Pagination: pagination, }) + return nil } // deleteSignupTokenHandler godoc @@ -157,16 +151,16 @@ func (h *handler) listSignupTokens(c *gin.Context) { // @Param id path string true "Token ID" // @Success 204 "No Content" // @Router /api/signup-tokens/{id} [delete] -func (h *handler) deleteSignupToken(c *gin.Context) { +func (h *handler) deleteSignupToken(c *gin.Context) error { tokenID := c.Param("id") err := h.service.DeleteSignupToken(c.Request.Context(), tokenID) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } // signupHandler godoc @@ -178,24 +172,21 @@ func (h *handler) deleteSignupToken(c *gin.Context) { // @Param user body signUpDto true "User information" // @Success 201 {object} dto.UserDto // @Router /api/signup [post] -func (h *handler) signup(c *gin.Context) { +func (h *handler) signup(c *gin.Context) error { config, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } var input signUpDto - err = dto.ShouldBindWithNormalizedJSON(c, &input) + err = httpserver.BindJSON(c, &input) if err != nil { - _ = c.Error(err) - return + return err } user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, c.ClientIP(), c.GetHeader("User-Agent")) if err != nil { - _ = c.Error(err) - return + return err } maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds()) @@ -204,9 +195,9 @@ func (h *handler) signup(c *gin.Context) { var userDto dto.UserDto err = dto.MapStruct(user, &userDto) if err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusCreated, userDto) + return nil } diff --git a/backend/internal/usersignup/migration.go b/backend/internal/usersignup/migration.go index 7d2d2cbb..5445adec 100644 --- a/backend/internal/usersignup/migration.go +++ b/backend/internal/usersignup/migration.go @@ -18,7 +18,7 @@ import ( // It's loaded here to create the per-token actors on first startup. // signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen. -const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential +const signupTokensMigratedKey = "signup_tokens_migrated" // #nosec G101 -- database key name, not a credential // migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration. // All timestamps are expressed as Unix seconds. diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index 7698ba6d..a5169a6d 100644 --- a/backend/internal/usersignup/module.go +++ b/backend/internal/usersignup/module.go @@ -11,6 +11,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -77,10 +78,10 @@ func (m *Module) RunSignupTokenMigration(ctx context.Context) error { // RegisterRoutes mounts the signup and signup-token management endpoints // adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) { - apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken) - apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens) - apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken) - apiGroup.POST("/signup", signupRateLimit, m.handler.signup) - apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable) - apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin) + apiGroup.POST("/signup-tokens", adminAuth, httpserver.Handle(m.handler.createSignupToken)) + apiGroup.GET("/signup-tokens", adminAuth, httpserver.Handle(m.handler.listSignupTokens)) + apiGroup.DELETE("/signup-tokens/:id", adminAuth, httpserver.Handle(m.handler.deleteSignupToken)) + apiGroup.POST("/signup", signupRateLimit, httpserver.Handle(m.handler.signup)) + apiGroup.GET("/signup/setup", httpserver.Handle(m.handler.checkInitialAdminSetupAvailable)) + apiGroup.POST("/signup/setup", httpserver.Handle(m.handler.signUpInitialAdmin)) } diff --git a/backend/internal/usersignup/service.go b/backend/internal/usersignup/service.go index 1d2ffed9..2b0ffc5d 100644 --- a/backend/internal/usersignup/service.go +++ b/backend/internal/usersignup/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -46,7 +47,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, tokenProvided := signupData.Token != "" if config.AllowUserSignups.String() != "open" && !tokenProvided { - return model.User{}, "", &common.OpenSignupDisabledError{} + return model.User{}, "", apperror.OpenSignupDisabled() } var userGroupIDs []string @@ -65,7 +66,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, } if consumeRes.Status != signupTokenConsumeOK { - return model.User{}, "", &common.TokenInvalidOrExpiredError{} + return model.User{}, "", apperror.TokenInvalidOrExpired() } userGroupIDs = consumeRes.UserGroupIDs } @@ -160,7 +161,7 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppC return model.User{}, "", err } if setupCompleted { - return model.User{}, "", &common.SetupNotAvailableError{} + return model.User{}, "", apperror.SetupAlreadyCompleted() } // Build the first user with administrator privileges diff --git a/backend/internal/usersignup/service_test.go b/backend/internal/usersignup/service_test.go index 3af38502..190e8792 100644 --- a/backend/internal/usersignup/service_test.go +++ b/backend/internal/usersignup/service_test.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -114,8 +114,7 @@ func TestSignUpRejectsInvalidToken(t *testing.T) { Token: "not-a-real-token", }, "1.2.3.4", "test-agent") - var invalidErr *common.TokenInvalidOrExpiredError - require.ErrorAs(t, err, &invalidErr) + require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired)) } func TestSignUpInitialAdminCreatesAdmin(t *testing.T) { @@ -154,8 +153,7 @@ func TestSignUpInitialAdminRejectsExistingInstallation(t *testing.T) { // Reject setup when the installation already contains a user _, _, err := svc.SignUpInitialAdmin(t.Context(), appconfig.NewTestConfig(nil), signUpDto{Username: "new-admin"}) - var setupNotAvailableErr *common.SetupNotAvailableError - require.ErrorAs(t, err, &setupNotAvailableErr) + require.True(t, apperror.IsCode(err, apperror.CodeSetupAlreadyCompleted)) } // listAllOptions returns list options that return every token on a single page. diff --git a/backend/internal/utils/callback_url_util_test.go b/backend/internal/utils/callback_url_util_test.go index 7bac62ad..cb49c17f 100644 --- a/backend/internal/utils/callback_url_util_test.go +++ b/backend/internal/utils/callback_url_util_test.go @@ -8,6 +8,9 @@ import ( ) func TestValidateCallbackURLPattern(t *testing.T) { + // #nosec G101 + const wildcardUserinfoPattern = "https://user:*@example.com/callback" + tests := []struct { name string pattern string @@ -35,7 +38,7 @@ func TestValidateCallbackURLPattern(t *testing.T) { }, { name: "wildcard userinfo", - pattern: "https://user:*@example.com/callback", // #nosec G101 - Test credential + pattern: wildcardUserinfoPattern, shouldError: false, }, { diff --git a/backend/internal/utils/cookie/cookie_names.go b/backend/internal/utils/cookie/cookie_names.go index 738dc4d8..ca23cba7 100644 --- a/backend/internal/utils/cookie/cookie_names.go +++ b/backend/internal/utils/cookie/cookie_names.go @@ -8,9 +8,9 @@ import ( var AccessTokenCookieName = "__Host-access_token" var SessionIdCookieName = "__Host-session" -var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec -var DeviceLoginTokenCookieName = "__Secure-device_login_token" //nolint:gosec -var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec +var DeviceTokenCookieName = "__Secure-device_token" // #nosec G101 -- cookie name, not a credential +var DeviceLoginTokenCookieName = "__Secure-device_login_token" // #nosec G101 -- cookie name, not a credential +var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" // #nosec G101 -- cookie name, not a credential func init() { if strings.HasPrefix(common.EnvConfig.AppURL, "http://") { diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go index 4c4debde..c1435e88 100644 --- a/backend/internal/utils/image/profile_picture.go +++ b/backend/internal/utils/image/profile_picture.go @@ -2,6 +2,7 @@ package profilepicture import ( "bytes" + "errors" "fmt" "image" "image/color" @@ -19,6 +20,8 @@ import ( const profilePictureSize = 300 +var ErrInvalidImage = errors.New("invalid image") + // CreateProfilePicture resizes the profile picture to a square and encodes it as PNG func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) { // Attempt standard formats first @@ -30,7 +33,10 @@ func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) { // Try WebP webpImg, webpErr := webp.Decode(file) if webpErr != nil { - return nil, fmt.Errorf("failed to decode image: %w", err) + return nil, fmt.Errorf("%w: %w", ErrInvalidImage, errors.Join( + fmt.Errorf("standard formats: %w", err), + fmt.Errorf("WebP: %w", webpErr), + )) } img = webpImg diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go index 77d9e9a9..06b5954f 100644 --- a/backend/internal/webauthn/handler.go +++ b/backend/internal/webauthn/handler.go @@ -7,8 +7,9 @@ import ( "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) @@ -21,113 +22,105 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler { return &handler{service: service, appConfig: appConfig} } -func (h *handler) beginRegistration(c *gin.Context) { +func (h *handler) beginRegistration(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } userID := c.GetString("userID") options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID) if err != nil { - _ = c.Error(err) - return + return err } cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) c.JSON(http.StatusOK, options.Response) + return nil } -func (h *handler) verifyRegistration(c *gin.Context) { +func (h *handler) verifyRegistration(c *gin.Context) error { sessionID, err := c.Cookie(cookie.SessionIdCookieName) if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return apperror.MissingSessionID() } userID := c.GetString("userID") credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) if err != nil { - _ = c.Error(err) - return + return err } var credentialDto dto.WebauthnCredentialDto if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, credentialDto) + return nil } -func (h *handler) beginLogin(c *gin.Context) { +func (h *handler) beginLogin(c *gin.Context) error { options, err := h.service.BeginLogin(c.Request.Context()) if err != nil { - _ = c.Error(err) - return + return err } cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) c.JSON(http.StatusOK, options.Response) + return nil } -func (h *handler) verifyLogin(c *gin.Context) { +func (h *handler) verifyLogin(c *gin.Context) error { dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return + return fmt.Errorf("error loading app configuration: %w", err) } sessionID, err := c.Cookie(cookie.SessionIdCookieName) if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return apperror.MissingSessionID() } credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) if err != nil { - _ = c.Error(err) - return + return apperror.InvalidWebAuthnResponse(err) } user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) if err != nil { - _ = c.Error(err) - return + return err } var userDto dto.UserDto if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + return err } maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) + return nil } -func (h *handler) listCredentials(c *gin.Context) { +func (h *handler) listCredentials(c *gin.Context) error { userID := c.GetString("userID") credentials, err := h.service.ListCredentials(c.Request.Context(), userID) if err != nil { - _ = c.Error(err) - return + return err } var credentialDtos []dto.WebauthnCredentialDto if err := dto.MapStructList(credentials, &credentialDtos); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, credentialDtos) + return nil } -func (h *handler) deleteCredential(c *gin.Context) { +func (h *handler) deleteCredential(c *gin.Context) error { userID := c.GetString("userID") credentialID := c.Param("id") clientIP := c.ClientIP() @@ -135,48 +128,46 @@ func (h *handler) deleteCredential(c *gin.Context) { err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID) if err != nil { - _ = c.Error(err) - return + return err } c.Status(http.StatusNoContent) + return nil } -func (h *handler) updateCredential(c *gin.Context) { +func (h *handler) updateCredential(c *gin.Context) error { userID := c.GetString("userID") credentialID := c.Param("id") var input dto.WebauthnCredentialUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return + if err := httpserver.BindJSON(c, &input); err != nil { + return err } credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name) if err != nil { - _ = c.Error(err) - return + return err } var credentialDto dto.WebauthnCredentialDto if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return + return err } c.JSON(http.StatusOK, credentialDto) + return nil } -func (h *handler) logout(c *gin.Context) { +func (h *handler) logout(c *gin.Context) error { cookie.AddAccessTokenCookie(c, 0, "") c.Status(http.StatusNoContent) + return nil } -func (h *handler) reauthenticate(c *gin.Context) { +func (h *handler) reauthenticate(c *gin.Context) error { sessionID, err := c.Cookie(cookie.SessionIdCookieName) if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return apperror.MissingSessionID() } var token string @@ -186,19 +177,18 @@ func (h *handler) reauthenticate(c *gin.Context) { if err == nil { token, err = h.service.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData) if err != nil { - _ = c.Error(err) - return + return err } } else { // If WebAuthn fails, try to create a reauthentication token with the access token accessToken, _ := c.Cookie(cookie.AccessTokenCookieName) token, err = h.service.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken) if err != nil { - _ = c.Error(err) - return + return err } } cookie.AddReauthenticationTokenCookie(c, token) c.Status(http.StatusNoContent) + return nil } diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go index 47017145..b26cefdd 100644 --- a/backend/internal/webauthn/module.go +++ b/backend/internal/webauthn/module.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/pocket-id/pocket-id/backend/internal/model" ) @@ -56,19 +57,19 @@ func New(deps Dependencies) (*Module, error) { // RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) { - apiGroup.GET("/webauthn/register/start", userAuth, m.handler.beginRegistration) - apiGroup.POST("/webauthn/register/finish", userAuth, m.handler.verifyRegistration) + apiGroup.GET("/webauthn/register/start", userAuth, httpserver.Handle(m.handler.beginRegistration)) + apiGroup.POST("/webauthn/register/finish", userAuth, httpserver.Handle(m.handler.verifyRegistration)) - apiGroup.GET("/webauthn/login/start", m.handler.beginLogin) - apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin) + apiGroup.GET("/webauthn/login/start", httpserver.Handle(m.handler.beginLogin)) + apiGroup.POST("/webauthn/login/finish", loginRateLimit, httpserver.Handle(m.handler.verifyLogin)) - apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout) + apiGroup.POST("/webauthn/logout", userAuth, httpserver.Handle(m.handler.logout)) - apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate) + apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, httpserver.Handle(m.handler.reauthenticate)) - apiGroup.GET("/webauthn/credentials", userAuth, m.handler.listCredentials) - apiGroup.PATCH("/webauthn/credentials/:id", userAuth, m.handler.updateCredential) - apiGroup.DELETE("/webauthn/credentials/:id", userAuth, m.handler.deleteCredential) + apiGroup.GET("/webauthn/credentials", userAuth, httpserver.Handle(m.handler.listCredentials)) + apiGroup.PATCH("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.updateCredential)) + apiGroup.DELETE("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.deleteCredential)) } // ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index 159f8f09..2780b5a2 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -14,7 +14,7 @@ import ( "gorm.io/gorm/clause" "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -26,6 +26,9 @@ const authenticationMethodPhishingResistant = "phr" const defaultRPDisplayName = "Pocket ID" +// go-webauthn exposes the missing user-verification reason only through DevInfo +const missingUserVerificationErrorInfo = "User verification required but flag not set by authenticator" + type Service struct { db *gorm.DB webAuthn *gowebauthn.WebAuthn @@ -79,8 +82,11 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App err := tx. WithContext(ctx). Preload("Credentials"). - Find(&user, "id = ?", userID). + First(&user, "id = ?", userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperror.UserNotFound() + } if err != nil { return nil, fmt.Errorf("failed to load user: %w", err) } @@ -138,7 +144,7 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", result.Error) } if result.RowsAffected == 0 { - return model.WebauthnCredential{}, &common.InvalidWebauthnSessionError{} + return model.WebauthnCredential{}, apperror.InvalidWebAuthnSession() } session := gowebauthn.SessionData{ @@ -152,15 +158,18 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user var user model.User err := tx. WithContext(ctx). - Find(&user, "id = ?", userID). + First(&user, "id = ?", userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.WebauthnCredential{}, apperror.UserNotFound() + } if err != nil { return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err) } credential, err := s.webAuthn.FinishRegistration(&user, session, r) if err != nil { - return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err) + return model.WebauthnCredential{}, classifyPasskeyError(err, apperror.InvalidWebAuthnResponse) } // Determine passkey name using AAGUID and User-Agent @@ -248,7 +257,7 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error) } if result.RowsAffected == 0 { - return model.User{}, "", &common.InvalidWebauthnSessionError{} + return model.User{}, "", apperror.InvalidWebAuthnSession() } session := gowebauthn.SessionData{ @@ -265,18 +274,25 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig Preload("Credentials"). First(&user, "id = ?", string(userHandle)). Error + // Preserve infrastructure failures through go-webauthn's wrapped callback error if innerErr != nil { + if !errors.Is(innerErr, gorm.ErrRecordNotFound) { + return nil, apperror.Internal(innerErr) + } return nil, innerErr } return user, nil }, session, credentialAssertionData) if err != nil { - return model.User{}, "", err + return model.User{}, "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed) + } + if user == nil { + return model.User{}, "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user")) } if user.Disabled { - return model.User{}, "", &common.UserDisabledError{} + return model.User{}, "", apperror.UserDisabled() } token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes()) @@ -321,7 +337,7 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia return fmt.Errorf("failed to delete record: %w", result.Error) } if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + return apperror.NotFound("Passkey") } auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name} @@ -331,6 +347,9 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia WithContext(ctx). First(&actor, "id = ?", actorUserID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.UserNotFound() + } if err != nil { return fmt.Errorf("failed to load actor user: %w", err) } @@ -359,6 +378,9 @@ func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, na Where("id = ? AND user_id = ?", credentialID, userID). First(&credential). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.WebauthnCredential{}, apperror.NotFound("Passkey") + } if err != nil { return credential, err } @@ -394,26 +416,26 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context token, err := s.signer.VerifyAccessToken(accessToken) if err != nil { - return "", fmt.Errorf("invalid access token: %w", err) + return "", apperror.ReauthenticationRequiredWithCause(err) } userID, ok := token.Subject() if !ok { - return "", errors.New("access token does not contain user ID") + return "", apperror.ReauthenticationRequiredWithCause(errors.New("access token does not contain user ID")) } authenticationMethod, err := s.signer.GetAuthenticationMethod(token) if err != nil { - return "", err + return "", apperror.ReauthenticationRequiredWithCause(err) } if authenticationMethod != authenticationMethodPhishingResistant { - return "", &common.ReauthenticationRequiredError{} + return "", apperror.ReauthenticationRequired() } // Check if token is issued less than a minute ago tokenExpiration, ok := token.IssuedAt() if !ok || time.Since(tokenExpiration) > time.Minute { - return "", &common.ReauthenticationRequiredError{} + return "", apperror.ReauthenticationRequired() } var user model.User @@ -421,6 +443,9 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context WithContext(ctx). First(&user, "id = ?", userID). Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", apperror.ReauthenticationRequiredWithCause(err) + } if err != nil { return "", fmt.Errorf("failed to load user: %w", err) } @@ -454,7 +479,7 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s return "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error) } if result.RowsAffected == 0 { - return "", &common.InvalidWebauthnSessionError{} + return "", apperror.InvalidWebAuthnSession() } session := gowebauthn.SessionData{ @@ -472,14 +497,21 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s Preload("Credentials"). First(&user, "id = ?", string(userHandle)). Error + // Preserve infrastructure failures through go-webauthn's wrapped callback error if innerErr != nil { + if !errors.Is(innerErr, gorm.ErrRecordNotFound) { + return nil, apperror.Internal(innerErr) + } return nil, innerErr } return user, nil }, session, credentialAssertionData) - if err != nil || user == nil { - return "", err + if err != nil { + return "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed) + } + if user == nil { + return "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user")) } // Create reauthentication token @@ -496,6 +528,20 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s return token, nil } +func classifyPasskeyError(err error, fallback func(error) *apperror.Error) *apperror.Error { + if appErr, ok := errors.AsType[*apperror.Error](err); ok { + return appErr + } + + if protocolError, ok := errors.AsType[*protocol.Error](err); ok && + protocolError.Type == protocol.ErrVerification.Type && + protocolError.DevInfo == missingUserVerificationErrorInfo { + return apperror.PasskeyUserVerificationRequired(err) + } + + return fallback(err) +} + func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { hashedToken := utils.CreateSha256Hash(token) var reauthToken ReauthenticationToken @@ -507,7 +553,7 @@ func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, return time.Time{}, result.Error } if result.RowsAffected == 0 { - return time.Time{}, &common.ReauthenticationRequiredError{} + return time.Time{}, apperror.ReauthenticationRequired() } return reauthToken.CreatedAt.UTC(), nil } diff --git a/backend/internal/webauthn/service_test.go b/backend/internal/webauthn/service_test.go index b5c08915..e67a94b2 100644 --- a/backend/internal/webauthn/service_test.go +++ b/backend/internal/webauthn/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -105,7 +106,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { assert.Empty(t, reauthenticationToken) require.Error(t, err) - assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError)) + assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) }) t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) { @@ -117,7 +118,16 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { assert.Empty(t, reauthenticationToken) require.Error(t, err) - assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError)) + assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) + }) + + t.Run("classifies an invalid access token as missing reauthentication", func(t *testing.T) { + service, _, _ := setupService(t) + + reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), "invalid") + + assert.Empty(t, reauthenticationToken) + require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired)) }) } @@ -133,6 +143,54 @@ func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) { require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName) } +func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) { + rpIDHash := make([]byte, 32) + authenticatorData := protocol.AuthenticatorData{ + RPIDHash: rpIDHash, + Flags: protocol.FlagUserPresent, + } + cause := authenticatorData.Verify(rpIDHash, nil, true, true) + require.Error(t, cause) + + err := classifyPasskeyError(cause, apperror.WebAuthnAuthenticationFailed) + + require.True(t, apperror.IsCode(err, apperror.CodePasskeyUserVerificationRequired)) + require.ErrorIs(t, err, cause) + + other := protocol.ErrVerification.WithInfo("RP Hash mismatch") + err = classifyPasskeyError(other, apperror.WebAuthnAuthenticationFailed) + require.True(t, apperror.IsCode(err, apperror.CodeWebAuthnAuthenticationFailed)) +} + +func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) { + // Wrap a structured database failure exactly as go-webauthn wraps callback errors + cause := errors.New("database unavailable") + lookupErr := protocol.ErrBadRequest.WithError(apperror.Internal(cause)) + + // Verify the structured failure and diagnostic cause both survive classification + err := classifyPasskeyError(lookupErr, apperror.WebAuthnAuthenticationFailed) + + require.True(t, apperror.IsCode(err, apperror.CodeInternal)) + require.ErrorIs(t, err, cause) +} + +func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) { + service, err := newService(Dependencies{ + DB: testutils.NewDatabaseForTest(t), + AppURL: "https://example.com", + }) + require.NoError(t, err) + + _, err = service.BeginRegistration(t.Context(), &appconfig.AppConfigModel{}, "missing-user") + require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound)) + + _, err = service.UpdateCredential(t.Context(), "missing-user", "missing-passkey", "New name") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) + + err = service.DeleteCredential(t.Context(), "missing-user", "missing-passkey", "", "", "") + require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) +} + // A ceremony that references a session which does not exist must be rejected outright // The delete-and-return leaves the struct zero-valued when nothing matched, and a zero session has an // empty user verification requirement, a zero expiry and an empty challenge, so letting it reach the @@ -158,7 +216,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) { _, err := service.VerifyRegistration(t.Context(), "does-not-exist", userID, nil, "127.0.0.1") require.Error(t, err) - assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError)) + assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession)) }) t.Run("login rejects an unknown session", func(t *testing.T) { @@ -168,7 +226,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) { assert.Empty(t, token) require.Error(t, err) - assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError)) + assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession)) }) t.Run("reauthentication rejects an unknown session", func(t *testing.T) { @@ -178,7 +236,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) { assert.Empty(t, token) require.Error(t, err) - assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError)) + assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession)) }) // The reauthentication query filters expired rows out in SQL, so an expired session matches no row @@ -197,7 +255,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) { assert.Empty(t, token) require.Error(t, err) - assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError)) + assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession)) }) } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c6d535e7..985d24a2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -38,17 +38,21 @@ "expiration": "Expiration", "name": "Name", "an_unknown_error_occurred": "An unknown error occurred", - "authentication_process_was_aborted": "The authentication process was aborted", - "error_occurred_with_authenticator": "An error occurred with the authenticator", - "authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials", - "authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys", + "authentication_process_was_aborted": "Passkey verification was canceled", + "error_occurred_with_authenticator": "Something went wrong while using your passkey", + "authenticator_does_not_support_discoverable_credentials": "This device does not support the type of passkey required", + "authenticator_does_not_support_resident_keys": "This device does not support the type of passkey required", "passkey_was_previously_registered": "This passkey was previously registered", - "authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms", - "webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.", - "webauthn_error_invalid_domain": "The configured domain is invalid.", + "authenticator_does_not_support_any_of_the_requested_algorithms": "This device does not support a compatible passkey", + "webauthn_error_invalid_rp_id": "Passkeys are not configured correctly for this domain.", + "webauthn_error_invalid_domain": "Passkeys cannot be used on the configured domain.", "contact_administrator_to_fix": "Contact your administrator to fix this issue.", - "webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out", + "webauthn_operation_not_allowed_or_timed_out": "The passkey prompt was canceled or timed out", "webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.", + "passkey_request_expired": "Your passkey request has expired", + "passkey_response_invalid": "We couldn't process the response from your passkey", + "passkey_verification_failed": "We couldn't verify your passkey", + "passkey_user_verification_required": "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again", "critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.", "sign_in_to": "Sign in to {name}", "account_selection_signin_confirmation": "Do you want to use the following account to continue to {#b}{name}{/b}?", diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 2f9fb085..0e2a9f3f 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,12 +1,14 @@ import type { HandleClientError } from '@sveltejs/kit'; -import { AxiosError } from 'axios'; +import { isAxiosError } from 'axios'; +import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util'; export const handleError: HandleClientError = async ({ error, message, status }) => { - if (error instanceof AxiosError) { - message = error.response?.data.error || message; + if (isAxiosError(error)) { + message = getAxiosErrorMessage(error, message); status = error.response?.status || status; console.error( - `Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}` + `Axios error: ${error.request?.path ?? 'unknown path'} - ${getAxiosErrorMessage(error, error.message)}`, + { requestId: getAxiosErrorRequestId(error) } ); } else { console.error(error); diff --git a/frontend/src/lib/utils/error-util.ts b/frontend/src/lib/utils/error-util.ts index d3f7e7c8..b1f8f697 100644 --- a/frontend/src/lib/utils/error-util.ts +++ b/frontend/src/lib/utils/error-util.ts @@ -1,17 +1,65 @@ import { m } from '$lib/paraglide/messages'; import { WebAuthnError } from '@simplewebauthn/browser'; -import { AxiosError } from 'axios'; +import { isAxiosError } from 'axios'; import { toast } from 'svelte-sonner'; +interface ApiErrorResponse { + error?: unknown; + code?: string; + request_id?: string; +} + +const codeMessages: Record string> = { + internal_error: () => m.an_unknown_error_occurred(), + request_timeout: () => m.please_try_again(), + rate_limited: () => m.please_try_again(), + not_signed_in: () => m.please_try_to_sign_in_again(), + reauthentication_required: () => m.please_try_to_sign_in_again(), + invalid_webauthn_session: () => m.passkey_request_expired(), + invalid_webauthn_response: () => m.passkey_response_invalid(), + webauthn_authentication_failed: () => m.passkey_verification_failed(), + passkey_user_verification_required: () => m.passkey_user_verification_required(), + device_login_expired: () => m.device_login_request_expired() +}; + +function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined { + if (!isAxiosError(e)) { + return undefined; + } + + const data = e.response?.data; + if (!data || typeof data !== 'object') { + return undefined; + } + + return data as ApiErrorResponse; +} + export function getAxiosErrorMessage( e: unknown, defaultMessage: string = m.an_unknown_error_occurred() ) { - let message = defaultMessage; - if (e instanceof AxiosError) { - message = e.response?.data.error || message; + const response = getApiErrorResponse(e); + if (!response) { + return defaultMessage; } - return message; + + const codeMessage = response.code ? codeMessages[response.code]?.() : undefined; + return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage); +} + +export function getAxiosErrorRequestId(e: unknown): string | undefined { + if (!isAxiosError(e)) { + return undefined; + } + + const bodyRequestId = getApiErrorResponse(e)?.request_id; + if (bodyRequestId) { + return bodyRequestId; + } + + const headerRequestId = e.response?.headers?.['x-request-id']; + return typeof headerRequestId === 'string' ? headerRequestId : undefined; } export function axiosErrorToast( @@ -39,13 +87,14 @@ export function getWebauthnErrorMessage(e: unknown) { NotAllowedError: m.webauthn_operation_not_allowed_or_timed_out() }; + const response = getApiErrorResponse(e); let message: string = m.an_unknown_error_occurred(); if (e instanceof WebAuthnError && e.code in errors) { message = errors[e.code as keyof typeof errors]; } else if (e instanceof WebAuthnError && e.cause instanceof Error && e.cause.name in errors) { message = errors[e.cause.name as keyof typeof errors]; - } else if (e instanceof AxiosError && e.response?.data.error) { - message = e.response?.data.error; + } else if (isAxiosError(e) && (response?.code || response?.error)) { + message = getAxiosErrorMessage(e); } else { console.error(e); } diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 65be769f..9d1d0ef2 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,7 +1,9 @@ import AppConfigService from '$lib/services/app-config-service'; import UserService from '$lib/services/user-service'; +import { m } from '$lib/paraglide/messages'; import appConfigStore from '$lib/stores/application-configuration-store'; import userStore from '$lib/stores/user-store'; +import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util'; import { setLocaleForLibraries } from '$lib/utils/locale.util'; import { getAuthRedirectPath } from '$lib/utils/redirection-util'; import { setTracingEnabled } from '$lib/utils/tracing-util'; @@ -17,8 +19,12 @@ export const load: LayoutLoad = async ({ url }) => { const userPromise = userService.getCurrent().catch(() => null); const appConfigPromise = appConfigService.list().catch((e) => { + const fallbackMessage = e instanceof Error ? e.message : m.an_unknown_error_occurred(); console.error( - `Failed to get application configuration: ${e.response?.data.error || e.message}` + `Failed to get application configuration: ${getAxiosErrorMessage(e, fallbackMessage)}`, + { + requestId: getAxiosErrorRequestId(e) + } ); return null; }); diff --git a/frontend/src/routes/login/alternative/email/+page.svelte b/frontend/src/routes/login/alternative/email/+page.svelte index 2290277e..ebd27834 100644 --- a/frontend/src/routes/login/alternative/email/+page.svelte +++ b/frontend/src/routes/login/alternative/email/+page.svelte @@ -6,6 +6,7 @@ import { m } from '$lib/paraglide/messages'; import UserService from '$lib/services/user-service'; import { preventDefault } from '$lib/utils/event-util'; + import { getAxiosErrorMessage } from '$lib/utils/error-util'; import { fade } from 'svelte/transition'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; @@ -23,7 +24,7 @@ await userService .requestOneTimeAccessEmailAsUnauthenticatedUser(email, data.redirect) .then(() => (success = true)) - .catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred())); + .catch((e) => (error = getAxiosErrorMessage(e))); isLoading = false; } diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 608366f3..b0179d7a 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -134,8 +134,8 @@ test('Email verification fails with expired token', async ({ page, context }) => await page.getByRole('button', { name: 'Authenticate' }).click(); await page.waitForURL( - '/settings/account?emailVerificationState=Invalid+email+verification+token' + '/settings/account?emailVerificationState=Email+verification+token+is+invalid' ); - await expect(page.getByText('Invalid email verification token')).toBeVisible(); + await expect(page.getByText('Email verification token is invalid')).toBeVisible(); }); diff --git a/tests/specs/api.spec.ts b/tests/specs/api.spec.ts index 613e6dab..5861132e 100644 --- a/tests/specs/api.spec.ts +++ b/tests/specs/api.spec.ts @@ -60,7 +60,9 @@ test('Cannot create an API with the issuer as resource', async ({ page }) => { await page.getByLabel('Resource').fill(issuer); await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.locator('[data-type="error"]')).toContainText('reserved'); + await expect(page.locator('[data-type="error"]')).toHaveText( + 'Resource is reserved by Pocket ID and cannot be used for a custom API' + ); }); test('Edit the name of an API', async ({ page }) => { diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index 8bc80fce..b02fd477 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -174,7 +174,7 @@ test.describe('Update application images', () => { await page.getByRole('button', { name: 'Save' }).click(); await expect(page.locator('[data-type="error"]')).toHaveText( - 'File must be of type .png or .jpg/jpeg' + 'File must be of type PNG or JPEG' ); }); }); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index a7001c38..b4afebaf 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -173,6 +173,7 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => { // Deselect "Yes" and select "No" to invert the filter await page.getByTestId('facet-par-option-true').click(); + await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible(); await page.getByTestId('facet-par-option-false').click(); // PAR client should be hidden, others visible diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index f556b331..79672638 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -128,7 +128,7 @@ test('Authorize new client while not signed in', async ({ page }) => { ); }); -test('Authorize new client fails with user group not allowed', async ({ page }) => { +test('Authorize new client shows Pocket ID error when user group not allowed', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); @@ -140,15 +140,12 @@ test('Authorize new client fails with user group not allowed', async ({ page }) await expectScopes(page, ['Email', 'Profile']); - const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => - page.getByRole('button', { name: 'Sign in' }).click() - ); + await page.getByRole('button', { name: 'Sign in' }).click(); - expect(callbackUrl.searchParams.get('error')).toBe('access_denied'); - expect(callbackUrl.searchParams.get('error_description')).toContain( - 'You are not allowed to access this service.' - ); - expect(callbackUrl.searchParams.get('state')).toBe(urlParams.get('state')); + await expect(page).toHaveURL(/\/interaction\/error\?error=/); + await expect( + page.getByRole('paragraph').filter({ hasText: 'You are not allowed to access this service.' }) + ).toBeVisible(); }); function createUrlParams(oidcClient: { id: string; callbackUrl: string }) { @@ -761,7 +758,7 @@ test('Authorize client with device authorization flow with invalid code', async await page.goto('/device?user_code=invalid-code'); await expect( - page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' }) + page.getByRole('paragraph').filter({ hasText: 'Device code is invalid. Please try again.' }) ).toBeVisible(); }); @@ -1101,7 +1098,7 @@ test.describe('OIDC prompt parameter', () => { await expect(selectionCard).toContainText('Tim Cook'); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => - page.getByRole('button', { name: 'Sign In' }).click() + page.getByRole('button', { name: /sign in/i }).click() ); }); @@ -1473,6 +1470,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => { } await page.getByRole('button', { name: /save/i }).click(); + await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); await page.reload(); await page.getByRole('button', { name: 'Show Advanced Options' }).click(); diff --git a/tests/specs/scim.spec.ts b/tests/specs/scim.spec.ts index 8b775d84..e487f771 100644 --- a/tests/specs/scim.spec.ts +++ b/tests/specs/scim.spec.ts @@ -98,11 +98,11 @@ test.describe('SCIM Sync', () => { test('Sync client', async ({ page }) => { await syncScimServiceProvider(page); + await expectScimResourceCount('Users', 2); const scimUsers = await getScimResources('Users'); - await expect(scimUsers.length).toBe(2); + await expectScimResourceCount('Groups', 2); const groups = await getScimResources('Groups'); - await expect(groups.length).toBe(2); const timUser = scimUsers.find((u: any) => u.userName === 'tim'); await expect(timUser).toBeDefined(); @@ -135,16 +135,18 @@ test.describe('SCIM Sync', () => { await expect(developersCheckbox).toHaveAttribute('data-state', 'unchecked'); await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('Allowed user groups updated successfully', { exact: true })).toBeVisible(); + await expect( + page.getByText('Allowed user groups updated successfully', { exact: true }) + ).toBeVisible(); await syncScimServiceProvider(page); + await expectScimResourceCount('Users', 1); const scimUsers = await getScimResources('Users'); - await expect(scimUsers.length).toBe(1); await expect(scimUsers.find((u: any) => u.userName === users.tim.username)).toBeDefined(); + await expectScimResourceCount('Groups', 1); const scimGroups = await getScimResources('Groups'); - await expect(scimGroups.length).toBe(1); await expect( scimGroups.find((g: any) => g.displayName === userGroups.designers.friendlyName) ).toBeDefined(); @@ -157,17 +159,21 @@ test.describe('SCIM Sync', () => { await page.getByRole('button', { name: 'Unrestrict' }).click(); await page.getByRole('button', { name: 'Unrestrict' }).nth(1).click(); + await expect( + page.getByText('User groups restriction updated successfully', { exact: true }) + ).toBeVisible(); await syncScimServiceProvider(page); - const scimUsers = await getScimResources('Users'); - await expect(scimUsers.length).toBe(3); - - const scimGroups = await getScimResources('Groups'); - await expect(scimGroups.length).toBe(2); + await expectScimResourceCount('Users', 3); + await expectScimResourceCount('Groups', 2); }); }); +async function expectScimResourceCount(resourceType: 'Users' | 'Groups', count: number) { + await expect.poll(async () => (await getScimResources(resourceType)).length).toBe(count); +} + async function getScimResources(resourceType: 'Users' | 'Groups') { const response = await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/${resourceType}`).then( (res) => res.json() diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 7b2129cd..db84d62b 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -99,7 +99,7 @@ test.describe('Initial User Signup', () => { ); const successfulResponses = responses.filter((response) => response.status() === 200); - const rejectedResponses = responses.filter((response) => response.status() === 404); + const rejectedResponses = responses.filter((response) => response.status() === 409); expect(successfulResponses).toHaveLength(1); expect(rejectedResponses).toHaveLength(requestCount - 1);