From f8b441fb0666d13e1a5dce404979851bf28b69f3 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 10 Jul 2026 16:31:45 -0500 Subject: [PATCH] feat: built in api documentation via huma gin adapter --- backend/go.mod | 5 +- backend/go.sum | 10 +- backend/internal/api/dto.go | 42 +- backend/internal/api/handler.go | 262 ++--- backend/internal/api/module.go | 40 +- backend/internal/api/service.go | 4 +- backend/internal/apikey/dto.go | 8 +- backend/internal/apikey/handler.go | 150 +-- backend/internal/apikey/module.go | 32 +- .../bootstrap/e2etest_router_bootstrap.go | 8 +- .../internal/bootstrap/router_bootstrap.go | 64 +- .../internal/bootstrap/router_huma_test.go | 109 +++ .../controller/app_config_controller.go | 178 ++-- .../controller/app_images_controller.go | 367 +++---- .../controller/audit_log_controller.go | 167 ++-- .../controller/custom_claim_controller.go | 130 +-- .../internal/controller/e2etest_controller.go | 192 ++-- .../internal/controller/oidc_controller.go | 690 +++++-------- .../internal/controller/scim_controller.go | 158 ++- .../internal/controller/user_controller.go | 918 +++++++----------- .../controller/user_group_controller.go | 309 +++--- .../internal/controller/version_controller.go | 70 +- .../controller/well_known_controller.go | 73 +- backend/internal/dto/app_config_dto.go | 110 ++- backend/internal/dto/custom_claim_dto.go | 4 +- backend/internal/dto/dto_normalize.go | 6 - backend/internal/dto/oidc_dto.go | 72 +- backend/internal/dto/one_time_access_dto.go | 28 +- backend/internal/dto/scim_dto.go | 6 +- backend/internal/dto/user_dto.go | 60 +- backend/internal/dto/user_group_dto.go | 25 +- backend/internal/dto/validations.go | 47 +- backend/internal/dto/webauthn_dto.go | 4 +- .../internal/middleware/auth_middleware.go | 149 ++- .../middleware/auth_middleware_test.go | 38 + backend/internal/middleware/error_handler.go | 55 -- .../internal/middleware/file_size_limit.go | 16 + .../middleware/file_size_limit_test.go | 49 + backend/internal/middleware/jwt_auth.go | 1 - backend/internal/middleware/rate_limit.go | 68 +- .../internal/middleware/rate_limit_test.go | 32 + backend/internal/model/types/date_time.go | 6 + .../internal/oidc/authorization_handler.go | 52 +- backend/internal/oidc/device_handler.go | 56 +- backend/internal/oidc/end_session_handler.go | 2 +- backend/internal/oidc/module.go | 44 +- backend/internal/service/oidc_service.go | 10 +- .../internal/service/user_group_service.go | 4 +- backend/internal/usersignup/dto.go | 24 +- backend/internal/usersignup/handler.go | 203 ++-- backend/internal/usersignup/module.go | 44 +- backend/internal/utils/cookie/add_cookie.go | 32 +- backend/internal/utils/csp.go | 20 + backend/internal/utils/http_util.go | 15 +- backend/internal/utils/huma/api.go | 118 +++ backend/internal/utils/huma/api_test.go | 162 ++++ backend/internal/utils/huma/context.go | 101 ++ backend/internal/utils/huma/errors.go | 98 ++ backend/internal/utils/huma/raw.go | 28 + backend/internal/utils/huma/register.go | 24 + backend/internal/utils/huma/types.go | 19 + backend/internal/utils/json_util.go | 9 +- backend/internal/utils/list_request_util.go | 35 +- backend/internal/webauthn/handler.go | 258 ++--- backend/internal/webauthn/handler_test.go | 49 + backend/internal/webauthn/module.go | 52 +- frontend/messages/en.json | 2 +- frontend/package.json | 130 +-- 68 files changed, 3239 insertions(+), 3114 deletions(-) create mode 100644 backend/internal/bootstrap/router_huma_test.go create mode 100644 backend/internal/middleware/file_size_limit_test.go create mode 100644 backend/internal/utils/huma/api.go create mode 100644 backend/internal/utils/huma/api_test.go create mode 100644 backend/internal/utils/huma/context.go create mode 100644 backend/internal/utils/huma/errors.go create mode 100644 backend/internal/utils/huma/raw.go create mode 100644 backend/internal/utils/huma/register.go create mode 100644 backend/internal/utils/huma/types.go create mode 100644 backend/internal/webauthn/handler_test.go diff --git a/backend/go.mod b/backend/go.mod index 86740638..c596f82f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,7 @@ require ( github.com/caarlos0/env/v11 v11.4.1 github.com/cenkalti/backoff/v5 v5.0.3 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/danielgtaylor/huma/v2 v2.38.0 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imaging v1.6.2 github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 @@ -20,7 +21,6 @@ require ( github.com/go-co-op/gocron/v2 v2.21.2 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 - github.com/go-playground/validator/v10 v10.30.3 github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 @@ -69,7 +69,7 @@ require ( github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect github.com/alphadose/haxmap v1.4.1 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect @@ -118,6 +118,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect diff --git a/backend/go.sum b/backend/go.sum index 60642734..19d4f73e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -14,8 +14,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q= github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= @@ -82,6 +82,8 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI= github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA= +github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY= +github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -519,8 +521,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik= +github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/backend/internal/api/dto.go b/backend/internal/api/dto.go index 0808cced..7a786c78 100644 --- a/backend/internal/api/dto.go +++ b/backend/internal/api/dto.go @@ -1,6 +1,8 @@ package api import ( + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/dto" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" ) @@ -23,25 +25,25 @@ type apiPermissionResponseDto struct { // apiCreateDto is the payload for creating an API // The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API type apiCreateDto struct { - Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"` - Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"` + Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"` + Resource string `json:"resource" required:"true" maxLength:"350" unorm:"nfc"` } // apiUpdateDto is the payload for updating an API // The resource identifier is intentionally not updatable type apiUpdateDto struct { - Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"` + Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"` } type apiPermissionInputDto struct { - Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"` - Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"` - Description *string `json:"description" binding:"omitempty,max=200"` + Key string `json:"key" required:"true" minLength:"1" maxLength:"128" unorm:"nfc"` + Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"` + Description *string `json:"description" required:"false" maxLength:"200"` } // apiPermissionsUpdateDto replaces the full permission set of an API type apiPermissionsUpdateDto struct { - Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"` + Permissions []apiPermissionInputDto `json:"permissions" required:"false"` } // clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type @@ -52,6 +54,28 @@ type clientApiAccessDto struct { } type clientApiAccessUpdateDto struct { - UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"` - ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"` + UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" required:"false"` + ClientPermissionIDs []string `json:"clientPermissionIds" required:"false"` +} + +func (d *apiCreateDto) Resolve(huma.Context) []error { + if dto.ValidateResourceURI(d.Resource) { + return nil + } + return []error{&huma.ErrorDetail{Location: "body.resource", Message: "Resource must be an absolute URI without whitespace or a fragment"}} +} + +func (d *clientApiAccessUpdateDto) Resolve(huma.Context) []error { + var errs []error + for _, id := range d.UserDelegatedPermissionIDs { + if id == "" { + errs = append(errs, &huma.ErrorDetail{Location: "body.userDelegatedPermissionIds", Message: "Permission IDs cannot be empty"}) + } + } + for _, id := range d.ClientPermissionIDs { + if id == "" { + errs = append(errs, &huma.ErrorDetail{Location: "body.clientPermissionIds", Message: "Permission IDs cannot be empty"}) + } + } + return errs } diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index ba64b02b..a256be4f 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -1,14 +1,44 @@ package api import ( - "net/http" - - "github.com/gin-gonic/gin" + "context" "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/utils" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) +type listInput struct { + httpapi.ListInput + Search string `query:"search" required:"false"` +} + +type idInput struct { + ID string `path:"id"` +} + +type createInput struct { + Body apiCreateDto +} + +type updateInput struct { + ID string `path:"id"` + Body apiUpdateDto +} + +type permissionsInput struct { + ID string `path:"id"` + Body apiPermissionsUpdateDto +} + +type clientInput struct { + ClientID string `path:"clientId"` +} + +type clientUpdateInput struct { + ClientID string `path:"clientId"` + Body clientApiAccessUpdateDto +} + type handler struct { service *Service } @@ -17,219 +47,93 @@ func newHandler(service *Service) *handler { return &handler{service: service} } -// list godoc -// @Summary List APIs -// @Description Get a paginated list of APIs with optional search and sorting -// @Tags APIs -// @Produce json -// @Param search query string false "Search term to filter APIs by name or resource" -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - search := c.Query("search") - listRequestOptions := utils.ParseListRequestOptions(c) - - apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions) +func (h *handler) list(ctx context.Context, input *listInput) (*httpapi.BodyOutput[dto.Paginated[apiResponseDto]], error) { + apis, pagination, err := h.service.List(ctx, input.Search, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, 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 + for i := range apis { + if err := dto.MapStruct(apis[i], &items[i]); err != nil { + return nil, err } - item.Resource = api.Audience - items[i] = item + items[i].Resource = apis[i].Audience } - - c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{ - Data: items, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[apiResponseDto]]{Body: dto.Paginated[apiResponseDto]{Data: items, Pagination: pagination}}, nil } -// get godoc -// @Summary Get API by ID -// @Description Retrieve a single API including its permissions -// @Tags APIs -// @Produce json -// @Param id path string true "API ID" -// @Success 200 {object} apiResponseDto -// @Router /api/apis/{id} [get] -func (h *handler) get(c *gin.Context) { - api, err := h.service.Get(c.Request.Context(), nil, c.Param("id")) +func (h *handler) get(ctx context.Context, input *idInput) (*httpapi.BodyOutput[apiResponseDto], error) { + item, err := h.service.Get(ctx, nil, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - h.respond(c, http.StatusOK, api) + return mapAPI(item) } -// create godoc -// @Summary Create API -// @Description Create a new API resource server -// @Tags APIs -// @Accept json -// @Produce json -// @Param api body apiCreateDto true "API information" -// @Success 201 {object} apiResponseDto "Created API" -// @Router /api/apis [post] -func (h *handler) create(c *gin.Context) { - var input apiCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - api, err := h.service.Create(c.Request.Context(), input) +func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiResponseDto], error) { + item, err := h.service.Create(ctx, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - h.respond(c, http.StatusCreated, api) + return mapAPI(item) } -// update godoc -// @Summary Update API -// @Description Update an existing API by ID -// @Tags APIs -// @Accept json -// @Produce json -// @Param id path string true "API ID" -// @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) { - var input apiUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - api, err := h.service.Update(c.Request.Context(), c.Param("id"), input) +func (h *handler) update(ctx context.Context, input *updateInput) (*httpapi.BodyOutput[apiResponseDto], error) { + item, err := h.service.Update(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - h.respond(c, http.StatusOK, api) + return mapAPI(item) } -// delete godoc -// @Summary Delete API -// @Description Delete an API by ID -// @Tags APIs -// @Param id path string true "API ID" -// @Success 204 "No Content" -// @Router /api/apis/{id} [delete] -func (h *handler) delete(c *gin.Context) { - if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil { - _ = c.Error(err) - return +func (h *handler) delete(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) { + if err := h.service.Delete(ctx, input.ID); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updatePermissions godoc -// @Summary Update API permissions -// @Description Replace the full set of permissions for an API -// @Tags APIs -// @Accept json -// @Produce json -// @Param id path string true "API ID" -// @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) { - var input apiPermissionsUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input) +func (h *handler) updatePermissions(ctx context.Context, input *permissionsInput) (*httpapi.BodyOutput[apiResponseDto], error) { + item, err := h.service.UpdatePermissions(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - h.respond(c, http.StatusOK, api) + return mapAPI(item) } -// getClientAccess godoc -// @Summary Get client API access -// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access -// @Tags APIs -// @Produce json -// @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) { - access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId")) +func (h *handler) getClientAccess(ctx context.Context, input *clientInput) (*httpapi.BodyOutput[clientApiAccessDto], error) { + access, err := h.service.GetClientAPIAccess(ctx, input.ClientID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, newClientApiAccessDto(access)) + return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(access)}, nil } -// updateClientAccess godoc -// @Summary Update client API access -// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access -// @Tags APIs -// @Accept json -// @Produce json -// @Param clientId path string true "OIDC Client ID" -// @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) { - var input clientApiAccessUpdateDto - err := c.ShouldBindJSON(&input) +func (h *handler) updateClientAccess(ctx context.Context, input *clientUpdateInput) (*httpapi.BodyOutput[clientApiAccessDto], error) { + applied, err := h.service.SetClientAPIAccess(ctx, input.ClientID, ClientAPIAccess(input.Body)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input)) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, newClientApiAccessDto(applied)) + return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(applied)}, nil } -// newClientApiAccessDto always serializes both permission lists as arrays rather than null -func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto { - dto := clientApiAccessDto(access) - if dto.UserDelegatedPermissionIDs == nil { - dto.UserDelegatedPermissionIDs = []string{} +func newClientAPIAccessDTO(access ClientAPIAccess) clientApiAccessDto { + output := clientApiAccessDto(access) + if output.UserDelegatedPermissionIDs == nil { + output.UserDelegatedPermissionIDs = []string{} } - if dto.ClientPermissionIDs == nil { - dto.ClientPermissionIDs = []string{} + if output.ClientPermissionIDs == nil { + output.ClientPermissionIDs = []string{} } - return dto + return output } -func (h *handler) respond(c *gin.Context, status int, api API) { - var responseDto apiResponseDto - if err := dto.MapStruct(api, &responseDto); err != nil { - _ = c.Error(err) - return +func mapAPI(item API) (*httpapi.BodyOutput[apiResponseDto], error) { + var output apiResponseDto + if err := dto.MapStruct(item, &output); err != nil { + return nil, err } - responseDto.Resource = api.Audience - c.JSON(status, responseDto) + output.Resource = item.Audience + return &httpapi.BodyOutput[apiResponseDto]{Body: output}, nil } diff --git a/backend/internal/api/module.go b/backend/internal/api/module.go index 68da6cdb..e331f5e4 100644 --- a/backend/internal/api/module.go +++ b/backend/internal/api/module.go @@ -2,12 +2,14 @@ package api import ( "context" + "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/oidc" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) type Dependencies struct { @@ -59,20 +61,26 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys } // RegisterRoutes mounts the admin CRUD endpoints -// adminAuth is passed in as a gin handler so the module does not import internal/middleware -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) +func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation)) { + register := func(operation huma.Operation, registerHandler func(huma.Operation)) { + adminAuth(&operation) + registerHandler(operation) + } - // 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) + register(apiOperation("list-apis", http.MethodGet, "/api/apis", "List APIs"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.list) }) + createOperation := apiOperation("create-api", http.MethodPost, "/api/apis", "Create API") + createOperation.DefaultStatus = http.StatusCreated + register(createOperation, func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.create) }) + register(apiOperation("get-api", http.MethodGet, "/api/apis/{id}", "Get API by ID"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.get) }) + register(apiOperation("update-api", http.MethodPut, "/api/apis/{id}", "Update API"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.update) }) + deleteOperation := apiOperation("delete-api", http.MethodDelete, "/api/apis/{id}", "Delete API") + deleteOperation.DefaultStatus = http.StatusNoContent + register(deleteOperation, func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.delete) }) + register(apiOperation("update-api-permissions", http.MethodPut, "/api/apis/{id}/permissions", "Update API permissions"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.updatePermissions) }) + register(apiOperation("get-client-api-access", http.MethodGet, "/api/api-access/{clientId}", "Get client API access"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.getClientAccess) }) + register(apiOperation("update-client-api-access", http.MethodPut, "/api/api-access/{clientId}", "Update client API access"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.updateClientAccess) }) +} + +func apiOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"APIs"}} } diff --git a/backend/internal/api/service.go b/backend/internal/api/service.go index 05b18d3d..ca6b1136 100644 --- a/backend/internal/api/service.go +++ b/backend/internal/api/service.go @@ -56,8 +56,8 @@ func (s *Service) List(ctx context.Context, search string, listRequestOptions ut Preload("Permissions"). Model(&API{}) - if listRequestOptions.Sort.Column == "resource" { - listRequestOptions.Sort.Column = "audience" + if listRequestOptions.SortColumn == "resource" { + listRequestOptions.SortColumn = "audience" } if search != "" { diff --git a/backend/internal/apikey/dto.go b/backend/internal/apikey/dto.go index 11b27fc5..0d3cddc4 100644 --- a/backend/internal/apikey/dto.go +++ b/backend/internal/apikey/dto.go @@ -5,13 +5,13 @@ import ( ) type apiKeyCreateDto struct { - Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"` - Description *string `json:"description" unorm:"nfc"` - ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` + Name string `json:"name" required:"true" minLength:"3" maxLength:"50" unorm:"nfc"` + Description *string `json:"description" required:"false" unorm:"nfc"` + ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"` } type apiKeyRenewDto struct { - ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` + ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"` } type apiKeyDto struct { diff --git a/backend/internal/apikey/handler.go b/backend/internal/apikey/handler.go index d11caf0d..4dcd25d9 100644 --- a/backend/internal/apikey/handler.go +++ b/backend/internal/apikey/handler.go @@ -1,14 +1,25 @@ package apikey import ( - "net/http" - - "github.com/gin-gonic/gin" + "context" "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/utils" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) +type createInput struct { + Body apiKeyCreateDto +} + +type renewInput struct { + ID string `path:"id"` + Body apiKeyRenewDto +} + +type idInput struct { + ID string `path:"id"` +} + type handler struct { service *Service } @@ -17,123 +28,46 @@ func newHandler(service *Service) *handler { return &handler{service: service} } -// list godoc -// @Summary List API keys -// @Description Get a paginated list of API keys belonging to the current user -// @Tags API Keys -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - userID := c.GetString("userID") - - apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions) +func (h *handler) list(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[apiKeyDto]], error) { + apiKeys, pagination, err := h.service.ListApiKeys(ctx, httpapi.UserID(ctx), input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - var apiKeysDto []apiKeyDto - if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { - _ = c.Error(err) - return + var output []apiKeyDto + if err := dto.MapStructList(apiKeys, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{ - Data: apiKeysDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[apiKeyDto]]{Body: dto.Paginated[apiKeyDto]{Data: output, Pagination: pagination}}, nil } -// create godoc -// @Summary Create API key -// @Description Create a new API key for the current user -// @Tags API Keys -// @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) { - userID := c.GetString("userID") - - var input apiKeyCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input) +func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) { + apiKey, token, err := h.service.CreateApiKey(ctx, httpapi.UserID(ctx), input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var responseDto apiKeyDto - if err := dto.MapStruct(apiKey, &responseDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, apiKeyResponseDto{ - ApiKey: responseDto, - Token: token, - }) + return mapAPIKeyResponse(apiKey, token) } -// renew godoc -// @Summary Renew API key -// @Description Renew an existing API key by ID -// @Tags API Keys -// @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) { - userID := c.GetString("userID") - apiKeyID := c.Param("id") - - var input apiKeyRenewDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime()) +func (h *handler) renew(ctx context.Context, input *renewInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) { + apiKey, token, err := h.service.RenewApiKey(ctx, httpapi.UserID(ctx), input.ID, input.Body.ExpiresAt.ToTime()) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var responseDto apiKeyDto - if err := dto.MapStruct(apiKey, &responseDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, apiKeyResponseDto{ - ApiKey: responseDto, - Token: token, - }) + return mapAPIKeyResponse(apiKey, token) } -// revoke godoc -// @Summary Revoke API key -// @Description Revoke (delete) an existing API key by ID -// @Tags API Keys -// @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) { - userID := c.GetString("userID") - apiKeyID := c.Param("id") - - if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil { - _ = c.Error(err) - return +func (h *handler) revoke(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) { + if err := h.service.RevokeApiKey(ctx, httpapi.UserID(ctx), input.ID); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil +} + +func mapAPIKeyResponse(apiKey ApiKey, token string) (*httpapi.BodyOutput[apiKeyResponseDto], error) { + var output apiKeyDto + if err := dto.MapStruct(apiKey, &output); err != nil { + return nil, err + } + return &httpapi.BodyOutput[apiKeyResponseDto]{Body: apiKeyResponseDto{ApiKey: output, Token: token}}, nil } diff --git a/backend/internal/apikey/module.go b/backend/internal/apikey/module.go index dbc18afc..2161e4a8 100644 --- a/backend/internal/apikey/module.go +++ b/backend/internal/apikey/module.go @@ -2,11 +2,13 @@ package apikey import ( "context" + "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/model" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) type Dependencies struct { @@ -33,12 +35,28 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) { // RegisterRoutes mounts the API key management endpoints // 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) +func (m *Module) RegisterRoutes(api huma.API, auth, authWithoutAPIKey func(*huma.Operation)) { + listOperation := apiKeyOperation("list-api-keys", http.MethodGet, "/api/api-keys", "List API keys") + auth(&listOperation) + httpapi.Register(api, listOperation, m.handler.list) + + createOperation := apiKeyOperation("create-api-key", http.MethodPost, "/api/api-keys", "Create API key") + createOperation.DefaultStatus = http.StatusCreated + authWithoutAPIKey(&createOperation) + httpapi.Register(api, createOperation, m.handler.create) + + renewOperation := apiKeyOperation("renew-api-key", http.MethodPost, "/api/api-keys/{id}/renew", "Renew API key") + authWithoutAPIKey(&renewOperation) + httpapi.Register(api, renewOperation, m.handler.renew) + + revokeOperation := apiKeyOperation("revoke-api-key", http.MethodDelete, "/api/api-keys/{id}", "Revoke API key") + revokeOperation.DefaultStatus = http.StatusNoContent + auth(&revokeOperation) + httpapi.Register(api, revokeOperation, m.handler.revoke) +} + +func apiKeyOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"API Keys"}} } // ValidateApiKey resolves the user that owns the given raw API key diff --git a/backend/internal/bootstrap/e2etest_router_bootstrap.go b/backend/internal/bootstrap/e2etest_router_bootstrap.go index b87d4644..7e27090f 100644 --- a/backend/internal/bootstrap/e2etest_router_bootstrap.go +++ b/backend/internal/bootstrap/e2etest_router_bootstrap.go @@ -6,7 +6,7 @@ import ( "log/slog" "os" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/controller" @@ -15,8 +15,8 @@ import ( // When building for E2E tests, add the e2etest controller func init() { - registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){ - func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { + registerTestControllers = []func(api huma.API, db *gorm.DB, svc *services){ + func(api huma.API, db *gorm.DB, svc *services) { testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage) if err != nil { slog.Error("Failed to initialize test service", slog.Any("error", err)) @@ -24,7 +24,7 @@ func init() { return } - controller.NewTestController(apiGroup, testService) + controller.NewTestController(api, testService) }, } } diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index c8cb5c81..dac3d89b 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + "github.com/danielgtaylor/huma/v2" "github.com/fsnotify/fsnotify" sloggin "github.com/gin-contrib/slog" "github.com/gin-gonic/gin" @@ -27,11 +28,12 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/controller" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/tracing" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" "github.com/pocket-id/pocket-id/backend/internal/utils/systemd" ) // This is used to register additional controllers for tests -var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) +var registerTestControllers []func(api huma.API, db *gorm.DB, svc *services) func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) { r, err := initEngine() @@ -137,45 +139,45 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices) - apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI) + baseGroup := r.Group("/", rateLimitMiddleware.Add(middleware.RateLimitAPI)) + apiGroup := baseGroup.Group("/api") + api := httpapi.New(r, baseGroup) - apiGroup := r.Group("/api", apiRateLimitMiddleware) - baseGroup := r.Group("/", apiRateLimitMiddleware) - - svc.apiKeyModule.RegisterRoutes(apiGroup, - authMiddleware.WithAdminNotRequired().Add(), - authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), + svc.apiKeyModule.RegisterRoutes(api, + authMiddleware.WithAdminNotRequired().Huma(api), + authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api), ) - svc.webauthnModule.RegisterRoutes(apiGroup, - authMiddleware.WithAdminNotRequired().Add(), - rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin), - rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), + svc.webauthnModule.RegisterRoutes(api, + authMiddleware.WithAdminNotRequired().Huma(api), + rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnLogin), + rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate), ) - controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) - controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) - controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) - controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) - controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) - controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService) - svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add()) - controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) - controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) - controller.NewScimController(apiGroup, authMiddleware, svc.scimService) - svc.userSignUpModule.RegisterRoutes(apiGroup, - authMiddleware.Add(), - rateLimitMiddleware.Add(middleware.RateLimitSignup), + controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) + controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) + controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) + controller.NewAppImagesController(api, authMiddleware, svc.appImagesService) + controller.NewAuditLogController(api, svc.auditLogService, authMiddleware) + controller.NewUserGroupController(api, authMiddleware, svc.userGroupService) + svc.apiModule.RegisterRoutes(api, authMiddleware.Huma(api)) + controller.NewCustomClaimController(api, authMiddleware, svc.customClaimService) + controller.NewVersionController(api, authMiddleware, svc.versionService) + controller.NewScimController(api, authMiddleware, svc.scimService) + svc.userSignUpModule.RegisterRoutes(api, + authMiddleware.Huma(api), + rateLimitMiddleware.Huma(api, middleware.RateLimitSignup), ) optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() - browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add() - svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth) + svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api) + svc.oidcModule.RegisterTypedRoutes(api, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api)) - registerTestRoutes(apiGroup, db, svc) + registerTestRoutes(api, db, svc) - controller.NewWellKnownController(baseGroup, svc.jwtService) + controller.NewWellKnownController(api, svc.jwtService) // These are not rate-limited. controller.NewHealthzController(r) + httpapi.AddRawOperation(api, "healthz", http.MethodGet, "/healthz", "Health check", []string{"Health"}, nil, http.StatusNoContent) // Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled. // Outside /api, so it's unauthenticated and not traced, but it is rate-limited. @@ -184,13 +186,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices return nil } -func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { +func registerTestRoutes(api huma.API, db *gorm.DB, svc *services) { if common.EnvConfig.AppEnv.IsProduction() { return } for _, f := range registerTestControllers { - f(apiGroup, db, svc) + f(api, db, svc) } } diff --git a/backend/internal/bootstrap/router_huma_test.go b/backend/internal/bootstrap/router_huma_test.go new file mode 100644 index 00000000..f78bce28 --- /dev/null +++ b/backend/internal/bootstrap/router_huma_test.go @@ -0,0 +1,109 @@ +package bootstrap + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/job" + "github.com/pocket-id/pocket-id/backend/internal/storage" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func TestHumaRouterOpenAPI(t *testing.T) { + originalConfig := common.EnvConfig + t.Cleanup(func() { common.EnvConfig = originalConfig }) + common.EnvConfig.AppEnv = common.AppEnvTest + common.EnvConfig.AppURL = "https://test.example.com" + common.EnvConfig.InternalAppURL = "https://test.example.com" + common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef") + common.EnvConfig.DisableRateLimiting = true + + db := testutils.NewDatabaseForTest(t) + fileStorage, err := storage.NewDatabaseStorage(db) + require.NoError(t, err) + scheduler, err := job.NewScheduler() + require.NoError(t, err) + services, err := initServices(t.Context(), db, http.DefaultClient, map[string]string{}, fileStorage, scheduler) + require.NoError(t, err) + router, err := initEngine() + require.NoError(t, err) + require.NoError(t, registerRoutes(router, db, services, nil)) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil)) + require.Equal(t, http.StatusOK, response.Code) + + var document struct { + Paths map[string]map[string]struct { + OperationID string `json:"operationId"` + Responses map[string]any `json:"responses"` + } `json:"paths"` + Components struct { + SecuritySchemes map[string]any `json:"securitySchemes"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &document)) + + for _, path := range []string{ + "/authorize", + "/.well-known/openid-configuration", + "/.well-known/jwks.json", + "/api/users", + "/api/user-groups", + "/api/oidc/token", + "/api/oidc/interactions/{id}", + "/api/webauthn/reauthenticate", + "/api/signup", + "/api/api-keys", + "/api/apis", + "/healthz", + } { + require.Contains(t, document.Paths, path) + } + + operationIDs := map[string]struct{}{} + for _, methods := range document.Paths { + for _, operation := range methods { + require.NotEmpty(t, operation.OperationID) + require.NotContains(t, operation.Responses, "422") + _, duplicate := operationIDs[operation.OperationID] + require.False(t, duplicate, "duplicate operation ID %q", operation.OperationID) + operationIDs[operation.OperationID] = struct{}{} + } + } + for _, scheme := range []string{"BearerAuth", "SessionCookie", "ApiKeyAuth", "OIDCAccessToken", "OIDCClientBasic"} { + require.Contains(t, document.Components.SecuritySchemes, scheme) + } + require.NotContains(t, response.Body.String(), `"$schema"`) + require.NotContains(t, response.Header().Get("Link"), "schema") + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users", nil)) + require.Equal(t, http.StatusUnauthorized, response.Code) + require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type")) + require.JSONEq(t, `{"error":"You are not signed in"}`, response.Body.String()) + + response = httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/signup/setup", nil) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(response, request) + require.Equal(t, http.StatusBadRequest, response.Code) + require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String()) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Header().Get("Content-Security-Policy"), "https://cdn.jsdelivr.net") + require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'") + require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5") + + response = httptest.NewRecorder() + newHTTPServer(router, nil).Handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodHead, "/healthz", nil)) + require.Equal(t, http.StatusNoContent, response.Code) + require.Empty(t, response.Body.String()) +} diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index c52d8b1c..7b7587c9 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -1,40 +1,58 @@ package controller import ( + "context" "net/http" "strconv" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" + "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/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/tracing" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewAppConfigController creates a new controller for application configuration endpoints -// @Summary Create a new application configuration controller -// @Description Initialize routes for application configuration -// @Tags Application Configuration +type appConfigUpdateInput struct { + Body dto.AppConfigUpdateDto +} + +// NewAppConfigController registers application configuration endpoints func NewAppConfigController( - group *gin.RouterGroup, + api huma.API, authMiddleware *middleware.AuthMiddleware, appConfigService *service.AppConfigService, emailService *service.EmailService, ldapService *service.LdapService, ) { + controller := &AppConfigController{appConfigService: appConfigService, emailService: emailService, ldapService: ldapService} - acc := &AppConfigController{ - appConfigService: appConfigService, - emailService: emailService, - 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) + httpapi.Register(api, appConfigOperation("list-public-application-configuration", http.MethodGet, "/api/application-configuration", "List public application configurations"), controller.listAppConfigHandler) - group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler) - group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler) + auth := authMiddleware.Huma(api) + allOperation := appConfigOperation("list-all-application-configuration", http.MethodGet, "/api/application-configuration/all", "List all application configurations") + auth(&allOperation) + httpapi.Register(api, allOperation, controller.listAllAppConfigHandler) + + updateOperation := appConfigOperation("update-application-configuration", http.MethodPut, "/api/application-configuration", "Update application configurations") + auth(&updateOperation) + httpapi.Register(api, updateOperation, controller.updateAppConfigHandler) + + testEmailOperation := appConfigOperation("test-email-configuration", http.MethodPost, "/api/application-configuration/test-email", "Send test email") + testEmailOperation.DefaultStatus = http.StatusNoContent + auth(&testEmailOperation) + httpapi.Register(api, testEmailOperation, controller.testEmailHandler) + + syncLDAPOperation := appConfigOperation("sync-ldap", http.MethodPost, "/api/application-configuration/sync-ldap", "Synchronize LDAP") + syncLDAPOperation.DefaultStatus = http.StatusNoContent + auth(&syncLDAPOperation) + httpapi.Register(api, syncLDAPOperation, controller.syncLDAPHandler) +} + +func appConfigOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Application Configuration"}} } type AppConfigController struct { @@ -43,121 +61,51 @@ type AppConfigController struct { ldapService *service.LdapService } -// listAppConfigHandler godoc -// @Summary List public application configurations -// @Description Get all public application configurations -// @Tags Application Configuration -// @Accept json -// @Produce json -// @Success 200 {array} dto.PublicAppConfigVariableDto -// @Router /api/application-configuration [get] -func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { +func (acc *AppConfigController) listAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto], error) { configuration := acc.appConfigService.ListAppConfig(false) - var configVariablesDto []dto.PublicAppConfigVariableDto - if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { - _ = c.Error(err) - return + var output []dto.PublicAppConfigVariableDto + if err := dto.MapStructList(configuration, &output); err != nil { + return nil, err } - - // Manually add uiConfigDisabled which isn't in the database but defined with an environment variable - configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{ - Key: "uiConfigDisabled", - Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled), - Type: "boolean", - }) - - // Manually add tracingEnabled, derived from the OTel environment, so the frontend only exports traces when the backend can forward them to a collector - configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{ - Key: "tracingEnabled", - Value: strconv.FormatBool(tracing.FrontendTracingEnabled()), - Type: "boolean", - }) - - c.JSON(http.StatusOK, configVariablesDto) + output = append(output, + dto.PublicAppConfigVariableDto{Key: "uiConfigDisabled", Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled), Type: "boolean"}, + dto.PublicAppConfigVariableDto{Key: "tracingEnabled", Value: strconv.FormatBool(tracing.FrontendTracingEnabled()), Type: "boolean"}, + ) + return &httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto]{Body: output}, nil } -// listAllAppConfigHandler godoc -// @Summary List all application configurations -// @Description Get all application configurations including private ones -// @Tags Application Configuration -// @Accept json -// @Produce json -// @Success 200 {array} dto.AppConfigVariableDto -// @Router /api/application-configuration/all [get] -func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { +func (acc *AppConfigController) listAllAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) { configuration := acc.appConfigService.ListAppConfig(true) - - var configVariablesDto []dto.AppConfigVariableDto - if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { - _ = c.Error(err) - return + var output []dto.AppConfigVariableDto + if err := dto.MapStructList(configuration, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, configVariablesDto) + return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil } -// updateAppConfigHandler godoc -// @Summary Update application configurations -// @Description Update application configuration settings -// @Tags Application Configuration -// @Accept json -// @Produce json -// @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) { - var input dto.AppConfigUpdateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input) +func (acc *AppConfigController) updateAppConfigHandler(ctx context.Context, input *appConfigUpdateInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) { + saved, err := acc.appConfigService.UpdateAppConfig(ctx, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var configVariablesDto []dto.AppConfigVariableDto - if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil { - _ = c.Error(err) - return + var output []dto.AppConfigVariableDto + if err := dto.MapStructList(saved, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, configVariablesDto) + return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil } -// syncLdapHandler godoc -// @Summary Synchronize LDAP -// @Description Manually trigger LDAP synchronization -// @Tags Application Configuration -// @Success 204 "No Content" -// @Router /api/application-configuration/sync-ldap [post] -func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { - err := acc.ldapService.SyncAll(c.Request.Context()) - if err != nil { - _ = c.Error(err) - return +func (acc *AppConfigController) syncLDAPHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := acc.ldapService.SyncAll(ctx); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// testEmailHandler godoc -// @Summary Send test email -// @Description Send a test email to verify email configuration -// @Tags Application Configuration -// @Success 204 "No Content" -// @Router /api/application-configuration/test-email [post] -func (acc *AppConfigController) testEmailHandler(c *gin.Context) { - userID := c.GetString("userID") - - err := acc.emailService.SendTestEmail(c.Request.Context(), userID) - if err != nil { - _ = c.Error(err) - return +func (acc *AppConfigController) testEmailHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := acc.emailService.SendTestEmail(ctx, httpapi.UserID(ctx)); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index 2230b6f0..3147a968 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -1,292 +1,219 @@ package controller import ( + "context" + "io" + "mime/multipart" "net/http" "slices" "strconv" "strings" "time" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/common" "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" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -func NewAppImagesController( - group *gin.RouterGroup, - authMiddleware *middleware.AuthMiddleware, - appImagesService *service.AppImagesService, -) { - controller := &AppImagesController{ - appImagesService: appImagesService, +type imageGetInput struct { + Light string `query:"light" default:"true" required:"false"` +} + +type imageUploadForm struct { + File huma.FormFile `form:"file" required:"true"` +} + +type imageUploadInput struct { + RawBody huma.MultipartFormFiles[imageUploadForm] +} + +type logoUploadInput struct { + Light string `query:"light" default:"true" required:"false"` + RawBody huma.MultipartFormFiles[imageUploadForm] +} + +type imageOutput struct { + ContentType string `header:"Content-Type"` + ContentLength int64 `header:"Content-Length"` + CacheControl string `header:"Cache-Control"` + Body func(huma.Context) +} + +func NewAppImagesController(api huma.API, authMiddleware *middleware.AuthMiddleware, appImagesService *service.AppImagesService) { + controller := &AppImagesController{appImagesService: appImagesService} + + httpapi.Register(api, imageOperation("get-application-logo", http.MethodGet, "/api/application-images/logo", "Get logo image"), controller.getLogoHandler) + httpapi.Register(api, imageOperation("get-email-logo", http.MethodGet, "/api/application-images/email", "Get email logo image"), controller.getEmailLogoHandler) + httpapi.Register(api, imageOperation("get-background-image", http.MethodGet, "/api/application-images/background", "Get background image"), controller.getBackgroundImageHandler) + httpapi.Register(api, imageOperation("get-favicon", http.MethodGet, "/api/application-images/favicon", "Get favicon"), controller.getFaviconHandler) + + auth := authMiddleware.Huma(api) + defaultPictureOperation := imageOperation("get-default-profile-picture", http.MethodGet, "/api/application-images/default-profile-picture", "Get default profile picture") + auth(&defaultPictureOperation) + httpapi.Register(api, defaultPictureOperation, controller.getDefaultProfilePicture) + + logoOperation := imageOperation("update-application-logo", http.MethodPut, "/api/application-images/logo", "Update logo") + logoOperation.DefaultStatus = http.StatusNoContent + auth(&logoOperation) + httpapi.Register(api, logoOperation, controller.updateLogoHandler) + + registerUpload := func(operation huma.Operation, handler func(context.Context, *imageUploadInput) (*httpapi.EmptyOutput, error)) { + operation.DefaultStatus = http.StatusNoContent + auth(&operation) + httpapi.Register(api, operation, handler) } + registerUpload(imageOperation("update-email-logo", http.MethodPut, "/api/application-images/email", "Update email logo"), controller.updateEmailLogoHandler) + registerUpload(imageOperation("update-background-image", http.MethodPut, "/api/application-images/background", "Update background image"), controller.updateBackgroundImageHandler) + registerUpload(imageOperation("update-favicon", http.MethodPut, "/api/application-images/favicon", "Update favicon"), controller.updateFaviconHandler) + registerUpload(imageOperation("update-default-profile-picture", http.MethodPut, "/api/application-images/default-profile-picture", "Update default profile picture"), controller.updateDefaultProfilePicture) - 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) + registerDelete := func(operation huma.Operation, handler func(context.Context, *httpapi.EmptyInput) (*httpapi.EmptyOutput, error)) { + operation.DefaultStatus = http.StatusNoContent + auth(&operation) + httpapi.Register(api, operation, handler) + } + registerDelete(imageOperation("delete-background-image", http.MethodDelete, "/api/application-images/background", "Delete background image"), controller.deleteBackgroundImageHandler) + registerDelete(imageOperation("delete-default-profile-picture", http.MethodDelete, "/api/application-images/default-profile-picture", "Delete default profile picture"), controller.deleteDefaultProfilePicture) +} - 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.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler) - group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture) +func imageOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Application Images"}} } type AppImagesController struct { appImagesService *service.AppImagesService } -// getLogoHandler godoc -// @Summary Get logo image -// @Description Get the logo image for the application -// @Tags Application Images -// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" -// @Produce image/png -// @Produce image/jpeg -// @Produce image/svg+xml -// @Success 200 {file} binary "Logo image" -// @Router /api/application-images/logo [get] -func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { - lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) +func (c *AppImagesController) getLogoHandler(ctx context.Context, input *imageGetInput) (*imageOutput, error) { + lightLogo, _ := strconv.ParseBool(input.Light) imageName := "logoLight" if !lightLogo { imageName = "logoDark" } - - c.getImage(ctx, imageName) + return c.getImage(ctx, imageName) } -// getEmailLogoHandler godoc -// @Summary Get email logo image -// @Description Get the email logo image for use in emails -// @Tags Application Images -// @Produce image/png -// @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 context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) { + return c.getImage(ctx, "logoEmail") } -// getBackgroundImageHandler godoc -// @Summary Get background image -// @Description Get the background image for the application -// @Tags Application Images -// @Produce image/png -// @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 context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) { + return c.getImage(ctx, "background") } -// getFaviconHandler godoc -// @Summary Get favicon -// @Description Get the favicon for the application -// @Tags Application Images -// @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 context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) { + return c.getImage(ctx, "favicon") } -// getDefaultProfilePicture godoc -// @Summary Get default profile picture image -// @Description Get the default profile picture image for the application -// @Tags Application Images -// @Produce image/png -// @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 context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) { + return c.getImage(ctx, "default-profile-picture") } -// updateLogoHandler godoc -// @Summary Update logo -// @Description Update the application logo -// @Tags Application Images -// @Accept multipart/form-data -// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" -// @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 context.Context, input *logoUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) + lightLogo, _ := strconv.ParseBool(input.Light) imageName := "logoLight" if !lightLogo { imageName = "logoDark" } - - if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil { - _ = ctx.Error(err) - return + if err := c.appImagesService.UpdateImage(ctx, file, imageName); err != nil { + return nil, err } - - ctx.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updateEmailLogoHandler godoc -// @Summary Update email logo -// @Description Update the email logo for use in emails -// @Tags Application Images -// @Accept multipart/form-data -// @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 context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - fileType := utils.GetFileExtension(file.Filename) - mimeType := utils.GetImageMimeType(fileType) - + mimeType := utils.GetImageMimeType(utils.GetFileExtension(file.Filename)) if mimeType != "image/png" && mimeType != "image/jpeg" { - _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}) - return + return nil, &common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"} } - - if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) + return c.updateImage(ctx, file, "logoEmail") } -// updateBackgroundImageHandler godoc -// @Summary Update background image -// @Description Update the application background image -// @Tags Application Images -// @Accept multipart/form-data -// @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 context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) + return c.updateImage(ctx, file, "background") } -// deleteBackgroundImageHandler godoc -// @Summary Delete background image -// @Description Delete the application background image -// @Tags Application Images -// @Success 204 "No Content" -// @Router /api/application-images/background [delete] -func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) { - if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) -} - -// updateFaviconHandler godoc -// @Summary Update favicon -// @Description Update the application favicon -// @Tags Application Images -// @Accept multipart/form-data -// @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 context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - fileType := utils.GetFileExtension(file.Filename) - mimeType := utils.GetImageMimeType(strings.ToLower(fileType)) + mimeType := utils.GetImageMimeType(strings.ToLower(utils.GetFileExtension(file.Filename))) 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 nil, &common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"} } - - if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) + return c.updateImage(ctx, file, "favicon") } -func (c *AppImagesController) getImage(ctx *gin.Context, name string) { - reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name) +func (c *AppImagesController) updateDefaultProfilePicture(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = ctx.Error(err) - return + return nil, 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 c.updateImage(ctx, file, "default-profile-picture") } -// updateDefaultProfilePicture godoc -// @Summary Update default profile picture image -// @Description Update the default profile picture image -// @Tags Application Images -// @Accept multipart/form-data -// @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) updateImage(ctx context.Context, file *multipart.FileHeader, name string) (*httpapi.EmptyOutput, error) { + if err := c.appImagesService.UpdateImage(ctx, file, name); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} + +func uploadFile(form *multipart.Form) (*multipart.FileHeader, error) { + files := form.File["file"] + if len(files) == 0 { + return nil, http.ErrMissingFile + } + return files[0], nil +} + +func (c *AppImagesController) deleteBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := c.appImagesService.DeleteImage(ctx, "background"); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} + +func (c *AppImagesController) deleteDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := c.appImagesService.DeleteImage(ctx, "default-profile-picture"); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} + +func (c *AppImagesController) getImage(ctx context.Context, name string) (*imageOutput, error) { + reader, size, mimeType, err := c.appImagesService.GetImage(ctx, name) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil { - _ = ctx.Error(err) - return + cacheControl := "" + if !httpapi.QueryPresent(ctx, "skipCache") { + cacheControl = utils.CacheControlValue(15*time.Minute, 24*time.Hour) } - - ctx.Status(http.StatusNoContent) -} - -// deleteDefaultProfilePicture godoc -// @Summary Delete default profile picture image -// @Description Delete the default profile picture image -// @Tags Application Images -// @Success 204 "No Content" -// @Router /api/application-images/default-profile-picture [delete] -func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) { - if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) + return &imageOutput{ + ContentType: mimeType, + ContentLength: size, + CacheControl: cacheControl, + Body: func(streamCtx huma.Context) { + defer reader.Close() + _, _ = io.Copy(streamCtx.BodyWriter(), reader) + }, + }, nil } diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go index 3eccac4b..13f0cb6a 100644 --- a/backend/internal/controller/audit_log_controller.go +++ b/backend/internal/controller/audit_log_controller.go @@ -1,145 +1,96 @@ package controller import ( + "context" "net/http" + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" - "github.com/pocket-id/pocket-id/backend/internal/utils" - - "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewAuditLogController creates a new controller for audit log management -// @Summary Audit log controller -// @Description Initializes API endpoints for accessing audit logs -// @Tags Audit Logs -func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) { - alc := AuditLogController{ - auditLogService: auditLogService, - } +// NewAuditLogController registers audit log routes +func NewAuditLogController(api huma.API, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) { + controller := &AuditLogController{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) + allOperation := huma.Operation{OperationID: "list-all-audit-logs", Method: http.MethodGet, Path: "/api/audit-logs/all", Summary: "List all audit logs", Tags: []string{"Audit Logs"}} + authMiddleware.Huma(api)(&allOperation) + httpapi.Register(api, allOperation, controller.listAllAuditLogsHandler) + + userOperation := huma.Operation{OperationID: "list-current-user-audit-logs", Method: http.MethodGet, Path: "/api/audit-logs", Summary: "List audit logs for the current user", Tags: []string{"Audit Logs"}} + authMiddleware.WithAdminNotRequired().Huma(api)(&userOperation) + httpapi.Register(api, userOperation, controller.listAuditLogsForUserHandler) + + clientsOperation := huma.Operation{OperationID: "list-audit-log-client-names", Method: http.MethodGet, Path: "/api/audit-logs/filters/client-names", Summary: "List client names", Tags: []string{"Audit Logs"}} + authMiddleware.Huma(api)(&clientsOperation) + httpapi.Register(api, clientsOperation, controller.listClientNamesHandler) + + usersOperation := huma.Operation{OperationID: "list-audit-log-users", Method: http.MethodGet, Path: "/api/audit-logs/filters/users", Summary: "List users with IDs", Tags: []string{"Audit Logs"}} + authMiddleware.Huma(api)(&usersOperation) + httpapi.Register(api, usersOperation, controller.listUserNamesWithIDsHandler) } type AuditLogController struct { auditLogService *service.AuditLogService } -// listAuditLogsForUserHandler godoc -// @Summary List audit logs -// @Description Get a paginated list of audit logs for the current user -// @Tags Audit Logs -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - userID := c.GetString("userID") - - // Fetch audit logs for the user - logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions) +func (alc *AuditLogController) listAuditLogsForUserHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) { + logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(ctx, httpapi.UserID(ctx), input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - // Map the audit logs to DTOs - var logsDtos []dto.AuditLogDto - err = dto.MapStructList(logs, &logsDtos) + logsDTOs, err := alc.mapAuditLogs(logs, false) if err != nil { - _ = c.Error(err) - return + return nil, err } - - // Add device information to the logs - for i, logsDto := range logsDtos { - logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent) - logsDto.ActorUsername = logsDto.Data["actorUsername"] - logsDtos[i] = logsDto - } - - c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{ - Data: logsDtos, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil } -// listAllAuditLogsHandler godoc -// @Summary List all audit logs -// @Description Get a paginated list of all audit logs (admin only) -// @Tags Audit Logs -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions) +func (alc *AuditLogController) listAllAuditLogsHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) { + logs, pagination, err := alc.auditLogService.ListAllAuditLogs(ctx, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - var logsDtos []dto.AuditLogDto - err = dto.MapStructList(logs, &logsDtos) + logsDTOs, err := alc.mapAuditLogs(logs, true) if err != nil { - _ = c.Error(err) - return + return nil, err } - - for i, logsDto := range logsDtos { - logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent) - logsDto.Username = logs[i].User.Username - logsDto.ActorUsername = logsDto.Data["actorUsername"] - logsDtos[i] = logsDto - } - - c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{ - Data: logsDtos, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil } -// listClientNamesHandler godoc -// @Summary List client names -// @Description Get a list of all client names for audit log filtering -// @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) { - names, err := alc.auditLogService.ListClientNames(c.Request.Context()) - if err != nil { - _ = c.Error(err) - return +func (alc *AuditLogController) mapAuditLogs(logs []model.AuditLog, includeUsername bool) ([]dto.AuditLogDto, error) { + var logsDTOs []dto.AuditLogDto + if err := dto.MapStructList(logs, &logsDTOs); err != nil { + return nil, err } - - c.JSON(http.StatusOK, names) + for i := range logsDTOs { + logsDTOs[i].Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent) + logsDTOs[i].ActorUsername = logsDTOs[i].Data["actorUsername"] + if includeUsername { + logsDTOs[i].Username = logs[i].User.Username + } + } + return logsDTOs, nil } -// listUserNamesWithIdsHandler godoc -// @Summary List users with IDs -// @Description Get a list of all usernames with their IDs for audit log filtering -// @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) { - users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context()) +func (alc *AuditLogController) listClientNamesHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) { + names, err := alc.auditLogService.ListClientNames(ctx) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, users) + return &httpapi.BodyOutput[[]string]{Body: names}, nil +} + +func (alc *AuditLogController) listUserNamesWithIDsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) { + users, err := alc.auditLogService.ListUsernamesWithIds(ctx) + if err != nil { + return nil, err + } + return &httpapi.BodyOutput[map[string]string]{Body: users}, nil } diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go index 8b4731c3..657caf7a 100644 --- a/backend/internal/controller/custom_claim_controller.go +++ b/backend/internal/controller/custom_claim_controller.go @@ -1,115 +1,79 @@ package controller import ( + "context" "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewCustomClaimController creates a new controller for custom claim management -// @Summary Custom claim management controller -// @Description Initializes all custom claim-related API endpoints -// @Tags Custom Claims -func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) { - wkc := &CustomClaimController{customClaimService: customClaimService} +type customClaimUserInput struct { + UserID string `path:"userId"` + Body []dto.CustomClaimCreateDto `required:"true"` +} - 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) - } +type customClaimUserGroupInput struct { + UserGroupID string `path:"userGroupId"` + Body []dto.CustomClaimCreateDto `required:"true"` +} + +// NewCustomClaimController registers custom claim management routes +func NewCustomClaimController(api huma.API, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) { + controller := &CustomClaimController{customClaimService: customClaimService} + auth := authMiddleware.Huma(api) + + suggestionsOperation := huma.Operation{OperationID: "list-custom-claim-suggestions", Method: http.MethodGet, Path: "/api/custom-claims/suggestions", Summary: "Get custom claim suggestions", Tags: []string{"Custom Claims"}} + auth(&suggestionsOperation) + httpapi.Register(api, suggestionsOperation, controller.getSuggestionsHandler) + + userOperation := huma.Operation{OperationID: "update-user-custom-claims", Method: http.MethodPut, Path: "/api/custom-claims/user/{userId}", Summary: "Update custom claims for a user", Tags: []string{"Custom Claims"}} + auth(&userOperation) + httpapi.Register(api, userOperation, controller.updateCustomClaimsForUserHandler) + + userGroupOperation := huma.Operation{OperationID: "update-user-group-custom-claims", Method: http.MethodPut, Path: "/api/custom-claims/user-group/{userGroupId}", Summary: "Update custom claims for a user group", Tags: []string{"Custom Claims"}} + auth(&userGroupOperation) + httpapi.Register(api, userGroupOperation, controller.updateCustomClaimsForUserGroupHandler) } type CustomClaimController struct { customClaimService *service.CustomClaimService } -// getSuggestionsHandler godoc -// @Summary Get custom claim suggestions -// @Description Get a list of suggested custom claim names -// @Tags Custom Claims -// @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) { - claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context()) +func (ccc *CustomClaimController) getSuggestionsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) { + claims, err := ccc.customClaimService.GetSuggestions(ctx) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, claims) + return &httpapi.BodyOutput[[]string]{Body: claims}, nil } -// UpdateCustomClaimsForUserHandler godoc -// @Summary Update custom claims for a user -// @Description Update or create custom claims for a specific user -// @Tags Custom Claims -// @Accept json -// @Produce json -// @Param userId path string true "User ID" -// @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) { - var input []dto.CustomClaimCreateDto - - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userId := c.Param("userId") - claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input) +func (ccc *CustomClaimController) updateCustomClaimsForUserHandler(ctx context.Context, input *customClaimUserInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) { + claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(ctx, input.UserID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - var customClaimsDto []dto.CustomClaimDto - if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return + var output []dto.CustomClaimDto + if err := dto.MapStructList(claims, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, customClaimsDto) + return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil } -// UpdateCustomClaimsForUserGroupHandler godoc -// @Summary Update custom claims for a user group -// @Description Update or create custom claims for a specific user group -// @Tags Custom Claims -// @Accept json -// @Produce json -// @Param userGroupId path string true "User Group ID" -// @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) { - var input []dto.CustomClaimCreateDto - - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userGroupId := c.Param("userGroupId") - claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input) +func (ccc *CustomClaimController) updateCustomClaimsForUserGroupHandler(ctx context.Context, input *customClaimUserGroupInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) { + claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(ctx, input.UserGroupID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - var customClaimsDto []dto.CustomClaimDto - if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return + var output []dto.CustomClaimDto + if err := dto.MapStructList(claims, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, customClaimsDto) + return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil } diff --git a/backend/internal/controller/e2etest_controller.go b/backend/internal/controller/e2etest_controller.go index d15bfbfb..d81f3c4a 100644 --- a/backend/internal/controller/e2etest_controller.go +++ b/backend/internal/controller/e2etest_controller.go @@ -3,150 +3,138 @@ package controller import ( + "context" + "encoding/json" "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -func NewTestController(group *gin.RouterGroup, testService *service.TestService) { - testController := &TestController{TestService: testService} +type testResetInput struct { + SkipLDAP string `query:"skip-ldap" required:"false"` + SkipSeed string `query:"skip-seed" required:"false"` +} - group.POST("/test/reset", testController.resetAndSeedHandler) - group.POST("/test/accesstoken", testController.signAccessToken) - group.POST("/test/refreshtoken", testController.signRefreshToken) +type testExternalIDPInput struct { + Body struct { + Audience string `json:"aud" required:"true"` + Issuer string `json:"iss" required:"true"` + Subject string `json:"sub" required:"true"` + } +} - group.GET("/externalidp/jwks.json", testController.externalIdPJWKS) - group.POST("/externalidp/sign", testController.externalIdPSignToken) +type testAccessTokenInput struct { + Body struct { + UserID string `json:"user" required:"true"` + ClientID string `json:"client" required:"true"` + Expired bool `json:"expired" required:"false"` + } +} + +type testRefreshTokenInput struct { + Body struct { + UserID string `json:"user" required:"true"` + ClientID string `json:"client" required:"true"` + RefreshToken string `json:"rt" required:"true"` + } +} + +type testBytesOutput struct { + ContentType string `header:"Content-Type"` + Body []byte +} + +func NewTestController(api huma.API, testService *service.TestService) { + controller := &TestController{TestService: testService} + + resetOperation := testOperation("test-reset", http.MethodPost, "/api/test/reset") + resetOperation.DefaultStatus = http.StatusNoContent + httpapi.Register(api, resetOperation, controller.resetAndSeedHandler) + httpapi.Register(api, testOperation("test-sign-access-token", http.MethodPost, "/api/test/accesstoken"), controller.signAccessToken) + httpapi.Register(api, testOperation("test-sign-refresh-token", http.MethodPost, "/api/test/refreshtoken"), controller.signRefreshToken) + httpapi.Register(api, testOperation("test-external-idp-jwks", http.MethodGet, "/api/externalidp/jwks.json"), controller.externalIDPJWKS) + httpapi.Register(api, testOperation("test-external-idp-sign", http.MethodPost, "/api/externalidp/sign"), controller.externalIDPSignToken) +} + +func testOperation(id, method, path string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Tags: []string{"E2E Test"}, Hidden: true} } type TestController struct { TestService *service.TestService } -func (tc *TestController) resetAndSeedHandler(c *gin.Context) { - var baseURL string - if c.Request.TLS != nil { - baseURL = "https://" + c.Request.Host - } else { - baseURL = "http://" + c.Request.Host +func (tc *TestController) resetAndSeedHandler(ctx context.Context, input *testResetInput) (*httpapi.EmptyOutput, error) { + request := httpapi.Request(ctx) + scheme := "http" + if request.TLS != nil { + scheme = "https" } - - skipLdap := c.Query("skip-ldap") == "true" - skipSeed := c.Query("skip-seed") == "true" + baseURL := scheme + "://" + request.Host if err := tc.TestService.ResetDatabase(); err != nil { - _ = c.Error(err) - return + return nil, err } - - if err := tc.TestService.ResetLock(c.Request.Context()); err != nil { - _ = c.Error(err) - return + if err := tc.TestService.ResetLock(ctx); err != nil { + return nil, err } - - if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil { - _ = c.Error(err) - return + if err := tc.TestService.ResetApplicationImages(ctx); err != nil { + return nil, err } - - if !skipSeed { + if input.SkipSeed != "true" { if err := tc.TestService.SeedDatabase(baseURL); err != nil { - _ = c.Error(err) - return + return nil, err } } - - if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil { - _ = c.Error(err) - return + if err := tc.TestService.ResetAppConfig(ctx); err != nil { + return nil, err } - - if !skipLdap { - if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil { - _ = c.Error(err) - return + if input.SkipLDAP != "true" { + if err := tc.TestService.SetLdapTestConfig(ctx); err != nil { + return nil, err } - - if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil { - _ = c.Error(err) - return + if err := tc.TestService.SyncLdap(ctx); err != nil { + return nil, err } } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -func (tc *TestController) externalIdPJWKS(c *gin.Context) { +func (tc *TestController) externalIDPJWKS(_ context.Context, _ *httpapi.EmptyInput) (*testBytesOutput, error) { jwks, err := tc.TestService.GetExternalIdPJWKS() if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, jwks) + body, err := json.Marshal(jwks) + if err != nil { + return nil, err + } + return &testBytesOutput{ContentType: "application/json; charset=utf-8", Body: body}, nil } -func (tc *TestController) externalIdPSignToken(c *gin.Context) { - var input struct { - Aud string `json:"aud"` - Iss string `json:"iss"` - Sub string `json:"sub"` - } - err := c.ShouldBindJSON(&input) +func (tc *TestController) externalIDPSignToken(_ context.Context, input *testExternalIDPInput) (*testBytesOutput, error) { + token, err := tc.TestService.SignExternalIdPToken(input.Body.Issuer, input.Body.Subject, input.Body.Audience) if err != nil { - _ = c.Error(err) - return + return nil, err } - - token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud) - if err != nil { - _ = c.Error(err) - return - } - - c.Writer.WriteString(token) + return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil } -func (tc *TestController) signAccessToken(c *gin.Context) { - var input struct { - UserID string `json:"user"` - ClientID string `json:"client"` - Expired bool `json:"expired"` - } - err := c.ShouldBindJSON(&input) +func (tc *TestController) signAccessToken(ctx context.Context, input *testAccessTokenInput) (*testBytesOutput, error) { + token, err := tc.TestService.SignAccessToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.Expired) if err != nil { - _ = c.Error(err) - return + return nil, err } - - token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired) - if err != nil { - _ = c.Error(err) - return - } - - c.Writer.WriteString(token) + return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil } -func (tc *TestController) signRefreshToken(c *gin.Context) { - var input struct { - UserID string `json:"user"` - ClientID string `json:"client"` - RefreshToken string `json:"rt"` - } - err := c.ShouldBindJSON(&input) +func (tc *TestController) signRefreshToken(ctx context.Context, input *testRefreshTokenInput) (*testBytesOutput, error) { + token, err := tc.TestService.SignRefreshToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.RefreshToken) if err != nil { - _ = c.Error(err) - return + return nil, err } - - token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken) - if err != nil { - _ = c.Error(err) - return - } - - c.Writer.WriteString(token) + return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 0033e918..0853910f 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -1,538 +1,368 @@ package controller import ( + "context" + "io" "net/http" "strconv" "strings" "time" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "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/middleware" + "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewOidcController creates a new controller for OIDC related endpoints -// @Summary OIDC controller -// @Description Initializes all OIDC-related API endpoints for authentication and client management -// @Tags OIDC -func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) { - oc := &OidcController{ - oidcService: oidcService, - } +type oidcClientIDInput struct { + ID string `path:"id"` +} - 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.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler) +type oidcClientListInput struct { + utils.ListRequestOptions + Search string `query:"search" required:"false"` +} - group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler) - group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler) +type oidcClientCreateInput struct { + Body dto.OidcClientCreateDto +} - 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) +type oidcClientUpdateInput struct { + ID string `path:"id"` + Body dto.OidcClientUpdateDto +} - group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler) +type oidcAllowedGroupsInput struct { + ID string `path:"id"` + Body dto.OidcUpdateAllowedUserGroupsDto +} - group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) - group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) +type oidcLogoInput struct { + ID string `path:"id"` + Light string `query:"light" default:"true" required:"false"` +} - group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler) +type oidcLogoUploadInput struct { + ID string `path:"id"` + Light string `query:"light" default:"true" required:"false"` + RawBody huma.MultipartFormFiles[imageUploadForm] +} - group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler) +type oidcUserAuthorizedClientsInput struct { + utils.ListRequestOptions + ID string `path:"id"` +} - group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler) +type oidcOwnAuthorizedClientsInput struct { + utils.ListRequestOptions +} +type oidcClientAuthorizationInput struct { + ClientID string `path:"clientId"` +} + +type oidcPreviewInput struct { + ID string `path:"id"` + UserID string `path:"userId"` + Scopes string `query:"scopes" required:"true"` +} + +type oidcLogoOutput struct { + ContentType string `header:"Content-Type"` + ContentLength int64 `header:"Content-Length"` + CacheControl string `header:"Cache-Control"` + Body func(huma.Context) +} + +// NewOidcController registers typed OIDC client management endpoints +func NewOidcController(api huma.API, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) { + controller := &OidcController{oidcService: oidcService} + adminAuth := authMiddleware.Huma(api) + userAuth := authMiddleware.WithAdminNotRequired().Huma(api) + + listClients := oidcOperation("list-oidc-clients", http.MethodGet, "/api/oidc/clients", "List OIDC clients") + adminAuth(&listClients) + httpapi.Register(api, listClients, controller.listClientsHandler) + + createClient := oidcOperation("create-oidc-client", http.MethodPost, "/api/oidc/clients", "Create OIDC client") + createClient.DefaultStatus = http.StatusCreated + adminAuth(&createClient) + httpapi.Register(api, createClient, controller.createClientHandler) + + getClient := oidcOperation("get-oidc-client", http.MethodGet, "/api/oidc/clients/{id}", "Get OIDC client") + adminAuth(&getClient) + httpapi.Register(api, getClient, controller.getClientHandler) + + httpapi.Register(api, oidcOperation("get-oidc-client-metadata", http.MethodGet, "/api/oidc/clients/{id}/meta", "Get OIDC client metadata"), controller.getClientMetaDataHandler) + + updateClient := oidcOperation("update-oidc-client", http.MethodPut, "/api/oidc/clients/{id}", "Update OIDC client") + adminAuth(&updateClient) + httpapi.Register(api, updateClient, controller.updateClientHandler) + + deleteClient := oidcOperation("delete-oidc-client", http.MethodDelete, "/api/oidc/clients/{id}", "Delete OIDC client") + deleteClient.DefaultStatus = http.StatusNoContent + adminAuth(&deleteClient) + httpapi.Register(api, deleteClient, controller.deleteClientHandler) + + allowedGroups := oidcOperation("update-oidc-client-allowed-user-groups", http.MethodPut, "/api/oidc/clients/{id}/allowed-user-groups", "Update allowed user groups") + adminAuth(&allowedGroups) + httpapi.Register(api, allowedGroups, controller.updateAllowedUserGroupsHandler) + + createSecret := oidcOperation("create-oidc-client-secret", http.MethodPost, "/api/oidc/clients/{id}/secret", "Create client secret") + adminAuth(&createSecret) + httpapi.Register(api, createSecret, controller.createClientSecretHandler) + + httpapi.Register(api, oidcOperation("get-oidc-client-logo", http.MethodGet, "/api/oidc/clients/{id}/logo", "Get client logo"), controller.getClientLogoHandler) + + deleteLogo := oidcOperation("delete-oidc-client-logo", http.MethodDelete, "/api/oidc/clients/{id}/logo", "Delete client logo") + deleteLogo.DefaultStatus = http.StatusNoContent + adminAuth(&deleteLogo) + httpapi.Register(api, deleteLogo, controller.deleteClientLogoHandler) + + updateLogo := oidcOperation("update-oidc-client-logo", http.MethodPost, "/api/oidc/clients/{id}/logo", "Update client logo") + updateLogo.DefaultStatus = http.StatusNoContent + adminAuth(&updateLogo) + updateLogo.Middlewares = append(updateLogo.Middlewares, fileSizeLimitMiddleware.Huma(api, 2<<20)) + httpapi.Register(api, updateLogo, controller.updateClientLogoHandler) + + preview := oidcOperation("preview-oidc-client-data", http.MethodGet, "/api/oidc/clients/{id}/preview/{userId}", "Preview OIDC client data for user") + adminAuth(&preview) + httpapi.Register(api, preview, controller.getClientPreviewHandler) + + ownAuthorized := oidcOperation("list-own-authorized-oidc-clients", http.MethodGet, "/api/oidc/users/me/authorized-clients", "List authorized clients for current user") + userAuth(&ownAuthorized) + httpapi.Register(api, ownAuthorized, controller.listOwnAuthorizedClientsHandler) + + userAuthorized := oidcOperation("list-user-authorized-oidc-clients", http.MethodGet, "/api/oidc/users/{id}/authorized-clients", "List authorized clients for a user") + adminAuth(&userAuthorized) + httpapi.Register(api, userAuthorized, controller.listAuthorizedClientsHandler) + + revokeAuthorization := oidcOperation("revoke-own-oidc-client-authorization", http.MethodDelete, "/api/oidc/users/me/authorized-clients/{clientId}", "Revoke authorization for an OIDC client") + revokeAuthorization.DefaultStatus = http.StatusNoContent + userAuth(&revokeAuthorization) + httpapi.Register(api, revokeAuthorization, controller.revokeOwnClientAuthorizationHandler) + + accessibleClients := oidcOperation("list-own-accessible-oidc-clients", http.MethodGet, "/api/oidc/users/me/clients", "List accessible OIDC clients for current user") + userAuth(&accessibleClients) + httpapi.Register(api, accessibleClients, controller.listOwnAccessibleClientsHandler) + + clientSCIM := oidcOperation("get-oidc-client-scim-service-provider", http.MethodGet, "/api/oidc/clients/{id}/scim-service-provider", "Get SCIM service provider") + adminAuth(&clientSCIM) + httpapi.Register(api, clientSCIM, controller.getClientScimServiceProviderHandler) +} + +func oidcOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"OIDC"}} } type OidcController struct { oidcService *service.OidcService } -// getClientMetaDataHandler godoc -// @Summary Get client metadata -// @Description Get OIDC client metadata for discovery and configuration -// @Tags OIDC -// @Produce json -// @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) { - clientId := c.Param("id") - client, err := oc.oidcService.GetClient(c.Request.Context(), clientId) +func (oc *OidcController) getClientMetaDataHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientMetaDataDto], error) { + client, err := oc.oidcService.GetClient(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - clientDto := dto.OidcClientMetaDataDto{} - err = dto.MapStruct(client, &clientDto) - if err == nil { - clientDto.HasDarkLogo = client.HasDarkLogo() - c.JSON(http.StatusOK, clientDto) - return + var output dto.OidcClientMetaDataDto + if err := dto.MapStruct(client, &output); err != nil { + return nil, err } - - _ = c.Error(err) + output.HasDarkLogo = client.HasDarkLogo() + return &httpapi.BodyOutput[dto.OidcClientMetaDataDto]{Body: output}, nil } -// getClientHandler godoc -// @Summary Get OIDC client -// @Description Get detailed information about an OIDC client -// @Tags OIDC -// @Produce json -// @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) { - clientId := c.Param("id") - client, err := oc.oidcService.GetClient(c.Request.Context(), clientId) +func (oc *OidcController) getClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) { + client, err := oc.oidcService.GetClient(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - clientDto := dto.OidcClientWithAllowedUserGroupsDto{} - err = dto.MapStruct(client, &clientDto) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, clientDto) + return mapOIDCClient(client) } -// listClientsHandler godoc -// @Summary List OIDC clients -// @Description Get a paginated list of OIDC clients with optional search and sorting -// @Tags OIDC -// @Param search query string false "Search term to filter clients by name" -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - searchTerm := c.Query("search") - listRequestOptions := utils.ParseListRequestOptions(c) - - clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions) +func (oc *OidcController) listClientsHandler(ctx context.Context, input *oidcClientListInput) (*httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]], error) { + clients, pagination, err := oc.oidcService.ListClients(ctx, input.Search, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - - // Map the user groups to DTOs - var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients)) - for i, client := range clients { - var clientDto dto.OidcClientWithAllowedGroupsCountDto - if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return + output := make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients)) + for i := range clients { + if err := dto.MapStruct(clients[i], &output[i]); err != nil { + return nil, err } - clientDto.HasDarkLogo = client.HasDarkLogo() - clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID) + output[i].HasDarkLogo = clients[i].HasDarkLogo() + output[i].AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(ctx, clients[i].ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - clientsDto[i] = clientDto } - - c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{ - Data: clientsDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]]{Body: dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{Data: output, Pagination: pagination}}, nil } -// createClientHandler godoc -// @Summary Create OIDC client -// @Description Create a new OIDC client -// @Tags OIDC -// @Accept json -// @Produce json -// @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) { - var input dto.OidcClientCreateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID")) +func (oc *OidcController) createClientHandler(ctx context.Context, input *oidcClientCreateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) { + client, err := oc.oidcService.CreateClient(ctx, input.Body, httpapi.UserID(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var clientDto dto.OidcClientWithAllowedUserGroupsDto - if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, clientDto) + return mapOIDCClient(client) } -// deleteClientHandler godoc -// @Summary Delete OIDC client -// @Description Delete an OIDC client by ID -// @Tags OIDC -// @Param id path string true "Client ID" -// @Success 204 "No Content" -// @Router /api/oidc/clients/{id} [delete] -func (oc *OidcController) deleteClientHandler(c *gin.Context) { - err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id")) - if err != nil { - _ = c.Error(err) - return +func (oc *OidcController) deleteClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.EmptyOutput, error) { + if err := oc.oidcService.DeleteClient(ctx, input.ID); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updateClientHandler godoc -// @Summary Update OIDC client -// @Description Update an existing OIDC client -// @Tags OIDC -// @Accept json -// @Produce json -// @Param id path string true "Client ID" -// @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) { - var input dto.OidcClientUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input) +func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcClientUpdateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) { + client, err := oc.oidcService.UpdateClient(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var clientDto dto.OidcClientWithAllowedUserGroupsDto - if err := dto.MapStruct(client, &clientDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, clientDto) + return mapOIDCClient(client) } -// createClientSecretHandler godoc -// @Summary Create client secret -// @Description Generate a new secret for an OIDC client -// @Tags OIDC -// @Produce json -// @Param id path string true "Client ID" -// @Success 200 {object} object "{ \"secret\": \"string\" }" -// @Router /api/oidc/clients/{id}/secret [post] -func (oc *OidcController) createClientSecretHandler(c *gin.Context) { - secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id")) +func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) { + secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, gin.H{"secret": secret}) + return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"secret": secret}}, nil } -// getClientLogoHandler godoc -// @Summary Get client logo -// @Description Get the logo image for an OIDC client -// @Tags OIDC -// @Produce image/png -// @Produce image/jpeg -// @Produce image/svg+xml -// @Param id path string true "Client ID" -// @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) { - lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) - - reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo) +func (oc *OidcController) getClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*oidcLogoOutput, error) { + light, _ := strconv.ParseBool(input.Light) + reader, size, mimeType, err := oc.oidcService.GetClientLogo(ctx, input.ID, light) if err != nil { - _ = c.Error(err) - return + return nil, err } - defer reader.Close() - - utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour) - - c.Header("Content-Type", mimeType) - c.DataFromReader(http.StatusOK, size, mimeType, reader, nil) + cacheControl := "" + if !httpapi.QueryPresent(ctx, "skipCache") { + cacheControl = utils.CacheControlValue(15*time.Minute, 12*time.Hour) + } + return &oidcLogoOutput{ + ContentType: mimeType, + ContentLength: size, + CacheControl: cacheControl, + Body: func(streamCtx huma.Context) { + defer reader.Close() + _, _ = io.Copy(streamCtx.BodyWriter(), reader) + }, + }, nil } -// updateClientLogoHandler godoc -// @Summary Update client logo -// @Description Upload or update the logo for an OIDC client -// @Tags OIDC -// @Accept multipart/form-data -// @Param id path string true "Client ID" -// @Param file formData file true "Logo image file (PNG, JPG, or SVG)" -// @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(ctx context.Context, input *oidcLogoUploadInput) (*httpapi.EmptyOutput, error) { + file, err := uploadFile(input.RawBody.Form) if err != nil { - _ = c.Error(err) - return + return nil, 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 + light, _ := strconv.ParseBool(input.Light) + if err := oc.oidcService.UpdateClientLogo(ctx, input.ID, file, light); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// deleteClientLogoHandler godoc -// @Summary Delete client logo -// @Description Delete the logo for an OIDC client -// @Tags OIDC -// @Param id path string true "Client ID" -// @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(ctx context.Context, input *oidcLogoInput) (*httpapi.EmptyOutput, error) { + light, _ := strconv.ParseBool(input.Light) var err error - - lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) - if lightLogo { - err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id")) + if light { + err = oc.oidcService.DeleteClientLogo(ctx, input.ID) } else { - err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id")) + err = oc.oidcService.DeleteClientDarkLogo(ctx, input.ID) } - if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updateAllowedUserGroupsHandler godoc -// @Summary Update allowed user groups -// @Description Update the user groups allowed to access an OIDC client -// @Tags OIDC -// @Accept json -// @Produce json -// @Param id path string true "Client ID" -// @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) { - var input dto.OidcUpdateAllowedUserGroupsDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input) +func (oc *OidcController) updateAllowedUserGroupsHandler(ctx context.Context, input *oidcAllowedGroupsInput) (*httpapi.BodyOutput[dto.OidcClientDto], error) { + client, err := oc.oidcService.UpdateAllowedUserGroups(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var oidcClientDto dto.OidcClientDto - if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil { - _ = c.Error(err) - return + var output dto.OidcClientDto + if err := dto.MapStruct(client, &output); err != nil { + return nil, err } - oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo() - - c.JSON(http.StatusOK, oidcClientDto) + output.HasDarkLogo = client.HasDarkLogo() + return &httpapi.BodyOutput[dto.OidcClientDto]{Body: output}, nil } -// listOwnAuthorizedClientsHandler godoc -// @Summary List authorized clients for current user -// @Description Get a paginated list of OIDC clients that the current user has authorized -// @Tags OIDC -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - userID := c.GetString("userID") - oc.listAuthorizedClients(c, userID) +func (oc *OidcController) listOwnAuthorizedClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) { + return oc.listAuthorizedClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions) } -// listAuthorizedClientsHandler godoc -// @Summary List authorized clients for a user -// @Description Get a paginated list of OIDC clients that a specific user has authorized -// @Tags OIDC -// @Param id path string true "User ID" -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - userID := c.Param("id") - oc.listAuthorizedClients(c, userID) +func (oc *OidcController) listAuthorizedClientsHandler(ctx context.Context, input *oidcUserAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) { + return oc.listAuthorizedClients(ctx, input.ID, input.ListRequestOptions) } -func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { - listRequestOptions := utils.ParseListRequestOptions(c) - - authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions) +func (oc *OidcController) listAuthorizedClients(ctx context.Context, userID string, options utils.ListRequestOptions) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) { + clients, pagination, err := oc.oidcService.ListAuthorizedClients(ctx, userID, options) if err != nil { - _ = c.Error(err) - return + return nil, err } - - // Map the clients to DTOs - var authorizedClientsDto []dto.AuthorizedOidcClientDto - if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil { - _ = c.Error(err) - return + var output []dto.AuthorizedOidcClientDto + if err := dto.MapStructList(clients, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{ - Data: authorizedClientsDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]]{Body: dto.Paginated[dto.AuthorizedOidcClientDto]{Data: output, Pagination: pagination}}, nil } -// revokeOwnClientAuthorizationHandler godoc -// @Summary Revoke authorization for an OIDC client -// @Description Revoke the authorization for a specific OIDC client for the current user -// @Tags OIDC -// @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) { - clientID := c.Param("clientId") +func (oc *OidcController) revokeOwnClientAuthorizationHandler(ctx context.Context, input *oidcClientAuthorizationInput) (*httpapi.EmptyOutput, error) { + if err := oc.oidcService.RevokeAuthorizedClient(ctx, httpapi.UserID(ctx), input.ClientID); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} - userID := c.GetString("userID") - - err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID) +func (oc *OidcController) listOwnAccessibleClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]], error) { + clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]]{Body: dto.Paginated[dto.AccessibleOidcClientDto]{Data: clients, Pagination: pagination}}, nil } -// listOwnAccessibleClientsHandler godoc -// @Summary List accessible OIDC clients for current user -// @Description Get a list of OIDC clients that the current user can access -// @Tags OIDC -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - userID := c.GetString("userID") - - clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions) +func (oc *OidcController) getClientPreviewHandler(ctx context.Context, input *oidcPreviewInput) (*httpapi.BodyOutput[dto.OidcClientPreviewDto], error) { + if input.ID == "" { + return nil, &common.ValidationError{Message: "client ID is required"} + } + if input.UserID == "" { + return nil, &common.ValidationError{Message: "user ID is required"} + } + if input.Scopes == "" { + return nil, &common.ValidationError{Message: "scopes are required"} + } + preview, err := oc.oidcService.GetClientPreview(ctx, input.ID, input.UserID, strings.Split(input.Scopes, " "), httpapi.AuthenticationMethod(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{ - Data: clients, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.OidcClientPreviewDto]{Body: *preview}, nil } -// getClientPreviewHandler godoc -// @Summary Preview OIDC client data for user -// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user -// @Tags OIDC -// @Produce json -// @Param id path string true "Client ID" -// @Param userId path string true "User ID to preview data for" -// @Param scopes query string false "Scopes to include in the preview (comma-separated)" -// @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) { - clientID := c.Param("id") - userID := c.Param("userId") - scopes := c.Query("scopes") - - if clientID == "" { - _ = c.Error(&common.ValidationError{Message: "client ID is required"}) - return - } - - if userID == "" { - _ = c.Error(&common.ValidationError{Message: "user ID is required"}) - return - } - - if scopes == "" { - _ = c.Error(&common.ValidationError{Message: "scopes are required"}) - return - } - - preview, err := oc.oidcService.GetClientPreview( - c.Request.Context(), - clientID, - userID, - strings.Split(scopes, " "), - c.GetString("authenticationMethod")) - +func (oc *OidcController) getClientScimServiceProviderHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) { + provider, err := oc.oidcService.GetClientScimServiceProvider(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, preview) + var output dto.ScimServiceProviderDTO + if err := dto.MapStruct(provider, &output); err != nil { + return nil, err + } + return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil } -// getClientScimServiceProviderHandler godoc -// @Summary Get SCIM service provider -// @Description Get the SCIM service provider configuration for an OIDC client -// @Tags OIDC -// @Produce json -// @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) { - clientID := c.Param("id") - - provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID) - if err != nil { - _ = c.Error(err) - return +func mapOIDCClient(client model.OidcClient) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) { + var output dto.OidcClientWithAllowedUserGroupsDto + if err := dto.MapStruct(client, &output); err != nil { + return nil, err } - - var providerDto dto.ScimServiceProviderDTO - if err := dto.MapStruct(provider, &providerDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, providerDto) + return &httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto]{Body: output}, nil } diff --git a/backend/internal/controller/scim_controller.go b/backend/internal/controller/scim_controller.go index a9762921..bfb73ea9 100644 --- a/backend/internal/controller/scim_controller.go +++ b/backend/internal/controller/scim_controller.go @@ -1,122 +1,96 @@ package controller import ( + "context" "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) { - ugc := ScimController{ - scimService: scimService, - } +type scimIDInput struct { + ID string `path:"id"` +} - 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) +type scimCreateInput struct { + Body dto.ScimServiceProviderCreateDTO +} + +type scimUpdateInput struct { + ID string `path:"id"` + Body dto.ScimServiceProviderCreateDTO +} + +func NewScimController(api huma.API, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) { + controller := &ScimController{scimService: scimService} + auth := authMiddleware.Huma(api) + + createOperation := scimOperation("create-scim-service-provider", http.MethodPost, "/api/scim/service-provider", "Create SCIM service provider") + createOperation.DefaultStatus = http.StatusCreated + auth(&createOperation) + httpapi.Register(api, createOperation, controller.createServiceProviderHandler) + + syncOperation := scimOperation("sync-scim-service-provider", http.MethodPost, "/api/scim/service-provider/{id}/sync", "Sync SCIM service provider") + syncOperation.DefaultStatus = http.StatusOK + auth(&syncOperation) + httpapi.Register(api, syncOperation, controller.syncServiceProviderHandler) + + updateOperation := scimOperation("update-scim-service-provider", http.MethodPut, "/api/scim/service-provider/{id}", "Update SCIM service provider") + auth(&updateOperation) + httpapi.Register(api, updateOperation, controller.updateServiceProviderHandler) + + deleteOperation := scimOperation("delete-scim-service-provider", http.MethodDelete, "/api/scim/service-provider/{id}", "Delete SCIM service provider") + deleteOperation.DefaultStatus = http.StatusNoContent + auth(&deleteOperation) + httpapi.Register(api, deleteOperation, controller.deleteServiceProviderHandler) +} + +func scimOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"SCIM"}} } type ScimController struct { scimService *service.ScimService } -// syncServiceProviderHandler godoc -// @Summary Sync SCIM service provider -// @Description Trigger synchronization for a SCIM service provider -// @Tags SCIM -// @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) { - err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id")) - if err != nil { - _ = ctx.Error(err) - return +func (c *ScimController) syncServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) { + if err := c.scimService.SyncServiceProvider(ctx, input.ID); err != nil { + return nil, err } - - ctx.Status(http.StatusOK) + return &httpapi.EmptyOutput{}, nil } -// createServiceProviderHandler godoc -// @Summary Create SCIM service provider -// @Description Create a new SCIM service provider -// @Tags SCIM -// @Accept json -// @Produce json -// @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) { - var input dto.ScimServiceProviderCreateDTO - if err := ctx.ShouldBindJSON(&input); err != nil { - _ = ctx.Error(err) - return - } - - provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input) +func (c *ScimController) createServiceProviderHandler(ctx context.Context, input *scimCreateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) { + provider, err := c.scimService.CreateServiceProvider(ctx, &input.Body) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - var providerDTO dto.ScimServiceProviderDTO - if err := dto.MapStruct(provider, &providerDTO); err != nil { - _ = ctx.Error(err) - return - } - - ctx.JSON(http.StatusCreated, providerDTO) + return mapSCIMProvider(provider) } -// updateServiceProviderHandler godoc -// @Summary Update SCIM service provider -// @Description Update an existing SCIM service provider -// @Tags SCIM -// @Accept json -// @Produce json -// @Param id path string true "Service Provider ID" -// @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) { - var input dto.ScimServiceProviderCreateDTO - if err := ctx.ShouldBindJSON(&input); err != nil { - _ = ctx.Error(err) - return - } - - provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input) +func (c *ScimController) updateServiceProviderHandler(ctx context.Context, input *scimUpdateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) { + provider, err := c.scimService.UpdateServiceProvider(ctx, input.ID, &input.Body) if err != nil { - _ = ctx.Error(err) - return + return nil, err } - - var providerDTO dto.ScimServiceProviderDTO - if err := dto.MapStruct(provider, &providerDTO); err != nil { - _ = ctx.Error(err) - return - } - - ctx.JSON(http.StatusOK, providerDTO) + return mapSCIMProvider(provider) } -// deleteServiceProviderHandler godoc -// @Summary Delete SCIM service provider -// @Description Delete a SCIM service provider by ID -// @Tags SCIM -// @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) { - err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id")) - if err != nil { - _ = ctx.Error(err) - return +func (c *ScimController) deleteServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) { + if err := c.scimService.DeleteServiceProvider(ctx, input.ID); err != nil { + return nil, err } - - ctx.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil +} + +func mapSCIMProvider(provider any) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) { + var output dto.ScimServiceProviderDTO + if err := dto.MapStruct(provider, &output); err != nil { + return nil, err + } + return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil } diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 615aa21e..db491f4d 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -1,63 +1,219 @@ package controller import ( + "context" + "io" "net/http" "time" - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + "github.com/danielgtaylor/huma/v2" - "github.com/gin-gonic/gin" + "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/middleware" + "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) const defaultOneTimeAccessTokenDuration = 15 * time.Minute -// NewUserController creates a new controller for user management endpoints -// @Summary User management controller -// @Description Initializes all user-related API endpoints -// @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { - uc := UserController{ - userService: userService, - oneTimeAccessService: oneTimeAccessService, - webAuthnService: webAuthnService, - appConfigService: appConfigService, - } +type userListInput struct { + utils.ListRequestOptions + Search string `query:"search" required:"false"` +} - 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) +type userIDInput struct { + ID string `path:"id"` +} - group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups) +type userCredentialIDInput struct { + ID string `path:"id"` + CredentialID string `path:"credentialId"` +} - group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler) +type userCreateInput struct { + Body dto.UserCreateDto +} - group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler) - group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler) +type userUpdateInput struct { + ID string `path:"id"` + Body dto.UserCreateDto +} - group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler) - group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) - group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler) - group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), uc.exchangeOneTimeAccessTokenHandler) - group.POST("/one-time-access-email", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler) +type userGroupsInput struct { + ID string `path:"id"` + Body dto.UserUpdateUserGroupDto +} - group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) - group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) +type userPictureUploadForm struct { + File huma.FormFile `form:"file" required:"true"` +} - group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler) - group.POST("/users/me/verify-email", rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler) +type userPictureUploadInput struct { + ID string `path:"id"` + RawBody huma.MultipartFormFiles[userPictureUploadForm] +} + +type currentUserPictureUploadInput struct { + RawBody huma.MultipartFormFiles[userPictureUploadForm] +} + +type oneTimeAccessOwnInput struct { + Body dto.OneTimeAccessTokenCreateDto +} + +type oneTimeAccessAdminInput struct { + ID string `path:"id"` + Body dto.OneTimeAccessTokenCreateDto +} + +type oneTimeAccessEmailAdminInput struct { + ID string `path:"id"` + Body dto.OneTimeAccessEmailAsAdminDto +} + +type oneTimeAccessEmailInput struct { + Body dto.OneTimeAccessEmailAsUnauthenticatedUserDto +} + +type oneTimeAccessExchangeInput struct { + Token string `path:"token"` +} + +type emailVerificationInput struct { + Body dto.EmailVerificationDto +} + +type userCookieOutput struct { + SetCookie []http.Cookie `header:"Set-Cookie"` + Body dto.UserDto +} + +type userPictureOutput struct { + ContentType string `header:"Content-Type"` + ContentLength int64 `header:"Content-Length"` + CacheControl string `header:"Cache-Control"` + Body func(huma.Context) +} + +// NewUserController registers user management endpoints +func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { + controller := &UserController{userService: userService, oneTimeAccessService: oneTimeAccessService, webAuthnService: webAuthnService, appConfigService: appConfigService} + adminAuth := authMiddleware.Huma(api) + userAuth := authMiddleware.WithAdminNotRequired().Huma(api) + + listUsers := userOperation("list-users", http.MethodGet, "/api/users", "List users") + adminAuth(&listUsers) + httpapi.Register(api, listUsers, controller.listUsersHandler) + + getCurrentUser := userOperation("get-current-user", http.MethodGet, "/api/users/me", "Get current user") + userAuth(&getCurrentUser) + httpapi.Register(api, getCurrentUser, controller.getCurrentUserHandler) + + getUser := userOperation("get-user", http.MethodGet, "/api/users/{id}", "Get user by ID") + adminAuth(&getUser) + httpapi.Register(api, getUser, controller.getUserHandler) + + createUser := userOperation("create-user", http.MethodPost, "/api/users", "Create user") + createUser.DefaultStatus = http.StatusCreated + adminAuth(&createUser) + httpapi.Register(api, createUser, controller.createUserHandler) + + updateUser := userOperation("update-user", http.MethodPut, "/api/users/{id}", "Update user") + adminAuth(&updateUser) + httpapi.Register(api, updateUser, controller.updateUserHandler) + + updateCurrentUser := userOperation("update-current-user", http.MethodPut, "/api/users/me", "Update current user") + userAuth(&updateCurrentUser) + httpapi.Register(api, updateCurrentUser, controller.updateCurrentUserHandler) + + getGroups := userOperation("get-user-groups", http.MethodGet, "/api/users/{id}/groups", "Get user groups") + adminAuth(&getGroups) + httpapi.Register(api, getGroups, controller.getUserGroupsHandler) + + listCredentials := userOperation("list-user-webauthn-credentials", http.MethodGet, "/api/users/{id}/webauthn-credentials", "List user passkeys") + adminAuth(&listCredentials) + httpapi.Register(api, listCredentials, controller.listUserWebauthnCredentialsHandler) + + deleteUser := userOperation("delete-user", http.MethodDelete, "/api/users/{id}", "Delete user") + deleteUser.DefaultStatus = http.StatusNoContent + adminAuth(&deleteUser) + httpapi.Register(api, deleteUser, controller.deleteUserHandler) + + deleteCredential := userOperation("delete-user-webauthn-credential", http.MethodDelete, "/api/users/{id}/webauthn-credentials/{credentialId}", "Delete user passkey") + deleteCredential.DefaultStatus = http.StatusNoContent + adminAuth(&deleteCredential) + httpapi.Register(api, deleteCredential, controller.deleteUserWebauthnCredentialHandler) + + updateGroups := userOperation("update-user-groups", http.MethodPut, "/api/users/{id}/user-groups", "Update user groups") + adminAuth(&updateGroups) + httpapi.Register(api, updateGroups, controller.updateUserGroups) + + httpapi.Register(api, userOperation("get-user-profile-picture", http.MethodGet, "/api/users/{id}/profile-picture.png", "Get user profile picture"), controller.getUserProfilePictureHandler) + + updatePicture := userOperation("update-user-profile-picture", http.MethodPut, "/api/users/{id}/profile-picture", "Update user profile picture") + updatePicture.DefaultStatus = http.StatusNoContent + adminAuth(&updatePicture) + httpapi.Register(api, updatePicture, controller.updateUserProfilePictureHandler) + + updateCurrentPicture := userOperation("update-current-user-profile-picture", http.MethodPut, "/api/users/me/profile-picture", "Update current user profile picture") + updateCurrentPicture.DefaultStatus = http.StatusNoContent + userAuth(&updateCurrentPicture) + httpapi.Register(api, updateCurrentPicture, controller.updateCurrentUserProfilePictureHandler) + + createOwnToken := userOperation("create-own-one-time-access-token", http.MethodPost, "/api/users/me/one-time-access-token", "Create one-time access token for current user") + createOwnToken.DefaultStatus = http.StatusCreated + userAuth(&createOwnToken) + httpapi.Register(api, createOwnToken, controller.createOwnOneTimeAccessTokenHandler) + + createAdminToken := userOperation("create-user-one-time-access-token", http.MethodPost, "/api/users/{id}/one-time-access-token", "Create one-time access token for user") + createAdminToken.DefaultStatus = http.StatusCreated + adminAuth(&createAdminToken) + httpapi.Register(api, createAdminToken, controller.createAdminOneTimeAccessTokenHandler) + + adminEmail := userOperation("request-user-one-time-access-email", http.MethodPost, "/api/users/{id}/one-time-access-email", "Request one-time access email for user") + adminEmail.DefaultStatus = http.StatusNoContent + adminAuth(&adminEmail) + httpapi.Register(api, adminEmail, controller.requestOneTimeAccessEmailAsAdminHandler) + + exchangeToken := userOperation("exchange-one-time-access-token", http.MethodPost, "/api/one-time-access-token/{token}", "Exchange one-time access token") + exchangeToken.Middlewares = append(exchangeToken.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken)) + httpapi.Register(api, exchangeToken, controller.exchangeOneTimeAccessTokenHandler) + + requestEmail := userOperation("request-one-time-access-email", http.MethodPost, "/api/one-time-access-email", "Request one-time access email") + requestEmail.DefaultStatus = http.StatusNoContent + requestEmail.Middlewares = append(requestEmail.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail)) + httpapi.Register(api, requestEmail, controller.requestOneTimeAccessEmailAsUnauthenticatedUserHandler) + + resetPicture := userOperation("reset-user-profile-picture", http.MethodDelete, "/api/users/{id}/profile-picture", "Reset user profile picture") + resetPicture.DefaultStatus = http.StatusNoContent + adminAuth(&resetPicture) + httpapi.Register(api, resetPicture, controller.resetUserProfilePictureHandler) + + resetCurrentPicture := userOperation("reset-current-user-profile-picture", http.MethodDelete, "/api/users/me/profile-picture", "Reset current user profile picture") + resetCurrentPicture.DefaultStatus = http.StatusNoContent + userAuth(&resetCurrentPicture) + httpapi.Register(api, resetCurrentPicture, controller.resetCurrentUserProfilePictureHandler) + + sendVerification := userOperation("send-email-verification", http.MethodPost, "/api/users/me/send-email-verification", "Send email verification") + sendVerification.DefaultStatus = http.StatusNoContent + sendVerification.Middlewares = append(sendVerification.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitSendEmailVerification)) + userAuth(&sendVerification) + httpapi.Register(api, sendVerification, controller.sendEmailVerificationHandler) + + verifyEmail := userOperation("verify-email", http.MethodPost, "/api/users/me/verify-email", "Verify email") + verifyEmail.DefaultStatus = http.StatusNoContent + verifyEmail.Middlewares = append(verifyEmail.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitVerifyEmail)) + userAuth(&verifyEmail) + httpapi.Register(api, verifyEmail, controller.verifyEmailHandler) +} + +func userOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Users"}} } type UserController struct { @@ -67,607 +223,243 @@ type UserController struct { appConfigService *service.AppConfigService } -// getUserGroupsHandler godoc -// @Summary Get user groups -// @Description Retrieve all groups a specific user belongs to -// @Tags Users,User Groups -// @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) { - userID := c.Param("id") - groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID) +func (uc *UserController) getUserGroupsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.UserGroupDto], error) { + groups, err := uc.userService.GetUserGroups(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var groupsDto []dto.UserGroupDto - if err := dto.MapStructList(groups, &groupsDto); err != nil { - _ = c.Error(err) - return + var output []dto.UserGroupDto + if err := dto.MapStructList(groups, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, groupsDto) + return &httpapi.BodyOutput[[]dto.UserGroupDto]{Body: output}, nil } -// listUserWebauthnCredentialsHandler godoc -// @Summary List user passkeys -// @Description Retrieve all WebAuthn credentials for a specific user -// @Tags Users -// @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) { - userID := c.Param("id") - - if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return +func (uc *UserController) listUserWebauthnCredentialsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.WebauthnCredentialDto], error) { + if _, err := uc.userService.GetUser(ctx, input.ID); err != nil { + return nil, err } - - credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID) + credentials, err := uc.webAuthnService.ListCredentials(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var credentialDtos []dto.WebauthnCredentialDto - if err := dto.MapStructList(credentials, &credentialDtos); err != nil { - _ = c.Error(err) - return + var output []dto.WebauthnCredentialDto + if err := dto.MapStructList(credentials, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, credentialDtos) + return &httpapi.BodyOutput[[]dto.WebauthnCredentialDto]{Body: output}, nil } -// listUsersHandler godoc -// @Summary List users -// @Description Get a paginated list of users with optional search and sorting -// @Tags Users -// @Param search query string false "Search term to filter users" -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - searchTerm := c.Query("search") - listRequestOptions := utils.ParseListRequestOptions(c) - - users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions) +func (uc *UserController) listUsersHandler(ctx context.Context, input *userListInput) (*httpapi.BodyOutput[dto.Paginated[dto.UserDto]], error) { + users, pagination, err := uc.userService.ListUsers(ctx, input.Search, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var usersDto []dto.UserDto - if err := dto.MapStructList(users, &usersDto); err != nil { - _ = c.Error(err) - return + var output []dto.UserDto + if err := dto.MapStructList(users, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{ - Data: usersDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.UserDto]]{Body: dto.Paginated[dto.UserDto]{Data: output, Pagination: pagination}}, nil } -// getUserHandler godoc -// @Summary Get user by ID -// @Description Retrieve detailed information about a specific user -// @Tags Users -// @Param id path string true "User ID" -// @Success 200 {object} dto.UserDto -// @Router /api/users/{id} [get] -func (uc *UserController) getUserHandler(c *gin.Context) { - user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id")) +func (uc *UserController) getUserHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[dto.UserDto], error) { + user, err := uc.userService.GetUser(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, userDto) + return mapUser(user) } -// getCurrentUserHandler godoc -// @Summary Get current user -// @Description Retrieve information about the currently authenticated user -// @Tags Users -// @Success 200 {object} dto.UserDto -// @Router /api/users/me [get] -func (uc *UserController) getCurrentUserHandler(c *gin.Context) { - user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID")) +func (uc *UserController) getCurrentUserHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[dto.UserDto], error) { + user, err := uc.userService.GetUser(ctx, httpapi.UserID(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, userDto) + return mapUser(user) } -// deleteUserHandler godoc -// @Summary Delete user -// @Description Delete a specific user by ID -// @Tags Users -// @Param id path string true "User ID" -// @Success 204 "No Content" -// @Router /api/users/{id} [delete] -func (uc *UserController) deleteUserHandler(c *gin.Context) { - if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil { - _ = c.Error(err) - return +func (uc *UserController) deleteUserHandler(ctx context.Context, input *userIDInput) (*httpapi.EmptyOutput, error) { + if err := uc.userService.DeleteUser(ctx, input.ID, false); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// deleteUserWebauthnCredentialHandler godoc -// @Summary Delete user passkey -// @Description Delete a specific WebAuthn credential for a user -// @Tags Users -// @Param id path string true "User ID" -// @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) { - err := uc.webAuthnService.DeleteCredential( - c.Request.Context(), - c.Param("id"), - c.Param("credentialId"), - c.ClientIP(), - c.Request.UserAgent(), - c.GetString("userID"), - ) +func (uc *UserController) deleteUserWebauthnCredentialHandler(ctx context.Context, input *userCredentialIDInput) (*httpapi.EmptyOutput, error) { + if err := uc.webAuthnService.DeleteCredential(ctx, input.ID, input.CredentialID, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx), httpapi.UserID(ctx)); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} + +func (uc *UserController) createUserHandler(ctx context.Context, input *userCreateInput) (*httpapi.BodyOutput[dto.UserDto], error) { + user, err := uc.userService.CreateUser(ctx, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.Status(http.StatusNoContent) + return mapUser(user) } -// createUserHandler godoc -// @Summary Create user -// @Description Create a new user -// @Tags Users -// @Param user body dto.UserCreateDto true "User information" -// @Success 201 {object} dto.UserDto -// @Router /api/users [post] -func (uc *UserController) createUserHandler(c *gin.Context) { - var input dto.UserCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } +func (uc *UserController) updateUserHandler(ctx context.Context, input *userUpdateInput) (*httpapi.BodyOutput[dto.UserDto], error) { + return uc.updateUser(ctx, input.ID, input.Body, false) +} - user, err := uc.userService.CreateUser(c.Request.Context(), input) +func (uc *UserController) updateCurrentUserHandler(ctx context.Context, input *userCreateInput) (*httpapi.BodyOutput[dto.UserDto], error) { + return uc.updateUser(ctx, httpapi.UserID(ctx), input.Body, true) +} + +func (uc *UserController) updateUser(ctx context.Context, userID string, input dto.UserCreateDto, ownUser bool) (*httpapi.BodyOutput[dto.UserDto], error) { + user, err := uc.userService.UpdateUser(ctx, userID, input, ownUser, false) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, userDto) + return mapUser(user) } -// updateUserHandler godoc -// @Summary Update user -// @Description Update an existing user by ID -// @Tags Users -// @Param id path string true "User ID" -// @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) -} - -// updateCurrentUserHandler godoc -// @Summary Update current user -// @Description Update the currently authenticated user's information -// @Tags Users -// @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) -} - -// getUserProfilePictureHandler godoc -// @Summary Get user profile picture -// @Description Retrieve a specific user's profile picture -// @Tags Users -// @Produce image/png -// @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) { - userID := c.Param("id") - - picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID) +func (uc *UserController) getUserProfilePictureHandler(ctx context.Context, input *userIDInput) (*userPictureOutput, error) { + picture, size, err := uc.userService.GetProfilePicture(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - if picture != nil { - defer picture.Close() + cacheControl := "" + if !httpapi.QueryPresent(ctx, "skipCache") { + cacheControl = utils.CacheControlValue(15*time.Minute, time.Hour) } - - utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour) - - c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) + return &userPictureOutput{ + ContentType: "image/png", + ContentLength: size, + CacheControl: cacheControl, + Body: func(streamCtx huma.Context) { + if picture != nil { + defer picture.Close() + _, _ = io.Copy(streamCtx.BodyWriter(), picture) + } + }, + }, nil } -// updateUserProfilePictureHandler godoc -// @Summary Update user profile picture -// @Description Update a specific user's profile picture -// @Tags Users -// @Accept multipart/form-data -// @Produce json -// @Param id path string true "User ID" -// @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) { - userID := c.Param("id") - fileHeader, err := c.FormFile("file") - if err != nil { - _ = c.Error(err) - return - } - file, err := fileHeader.Open() - if err != nil { - _ = c.Error(err) - return - } +func (uc *UserController) updateUserProfilePictureHandler(ctx context.Context, input *userPictureUploadInput) (*httpapi.EmptyOutput, error) { + return uc.updateProfilePicture(ctx, input.ID, input.RawBody.Data().File) +} + +func (uc *UserController) updateCurrentUserProfilePictureHandler(ctx context.Context, input *currentUserPictureUploadInput) (*httpapi.EmptyOutput, error) { + return uc.updateProfilePicture(ctx, httpapi.UserID(ctx), input.RawBody.Data().File) +} + +func (uc *UserController) updateProfilePicture(ctx context.Context, userID string, file huma.FormFile) (*httpapi.EmptyOutput, error) { defer file.Close() - - if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil { - _ = c.Error(err) - return + if err := uc.userService.UpdateProfilePicture(ctx, userID, file); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updateCurrentUserProfilePictureHandler godoc -// @Summary Update current user's profile picture -// @Description Update the currently authenticated user's profile picture -// @Tags Users -// @Accept multipart/form-data -// @Produce json -// @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) { - userID := c.GetString("userID") - fileHeader, err := c.FormFile("file") - if err != nil { - _ = c.Error(err) - return - } - file, err := fileHeader.Open() - if err != nil { - _ = c.Error(err) - return - } - defer file.Close() - - if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) +func (uc *UserController) createOwnOneTimeAccessTokenHandler(ctx context.Context, _ *oneTimeAccessOwnInput) (*httpapi.BodyOutput[map[string]string], error) { + return uc.createOneTimeAccessToken(ctx, httpapi.UserID(ctx), defaultOneTimeAccessTokenDuration) } -func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) { - var input dto.OneTimeAccessTokenCreateDto - err := c.ShouldBindJSON(&input) - if err != nil { - _ = c.Error(err) - return - } - - var ( - userID string - ttl time.Duration - ) - if own { - // Get user ID from context and force the default TTL - userID = c.GetString("userID") - ttl = defaultOneTimeAccessTokenDuration - } else { - // Get user ID from URL parameter, and optional TTL from body - userID = c.Param("id") - ttl = input.TTL.Duration - if ttl <= 0 { - ttl = defaultOneTimeAccessTokenDuration - } - } - if userID == "" { - _ = c.Error(&common.UserIdNotProvidedError{}) - return - } - - token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, gin.H{"token": token}) -} - -// createOwnOneTimeAccessTokenHandler godoc -// @Summary Create one-time access token for current user -// @Description Generate a one-time access token for the currently authenticated user -// @Tags Users -// @Param id path string true "User ID" -// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options" -// @Success 201 {object} object "{ \"token\": \"string\" }" -// @Router /api/users/{id}/one-time-access-token [post] -func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) { - uc.createOneTimeAccessTokenHandler(c, true) -} - -// createAdminOneTimeAccessTokenHandler godoc -// @Summary Create one-time access token for user (admin) -// @Description Generate a one-time access token for a specific user (admin only) -// @Tags Users -// @Param id path string true "User ID" -// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options" -// @Success 201 {object} object "{ \"token\": \"string\" }" -// @Router /api/users/{id}/one-time-access-token [post] -func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { - uc.createOneTimeAccessTokenHandler(c, false) -} - -// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc -// @Summary Request one-time access email -// @Description Request a one-time access email for unauthenticated users -// @Tags Users -// @Accept json -// @Produce json -// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information" -// @Success 204 "No Content" -// @Router /api/one-time-access-email [post] -func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) { - var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) - if err != nil { - _ = c.Error(err) - return - } - - cookie.AddDeviceTokenCookie(c, deviceToken) - c.Status(http.StatusNoContent) -} - -// RequestOneTimeAccessEmailAsAdminHandler godoc -// @Summary Request one-time access email (admin) -// @Description Request a one-time access email for a specific user (admin only) -// @Tags Users -// @Accept json -// @Produce json -// @Param id path string true "User ID" -// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options" -// @Success 204 "No Content" -// @Router /api/users/{id}/one-time-access-email [post] -func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) { - var input dto.OneTimeAccessEmailAsAdminDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - userID := c.Param("id") - - ttl := input.TTL.Duration +func (uc *UserController) createAdminOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessAdminInput) (*httpapi.BodyOutput[map[string]string], error) { + ttl := input.Body.TTL.Duration if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } - err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) - if err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) + return uc.createOneTimeAccessToken(ctx, input.ID, ttl) } -// exchangeOneTimeAccessTokenHandler godoc -// @Summary Exchange one-time access token -// @Description Exchange a one-time access token for a session token -// @Tags Users -// @Param token path string true "One-time access token" -// @Success 200 {object} dto.UserDto -// @Router /api/one-time-access-token/{token} [post] -func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { - loginCode := c.Param("token") - // reject invalid length login codes - if len(loginCode) != 6 && len(loginCode) != 16 { - _ = c.Error(&common.TokenInvalidOrExpiredError{}) - return +func (uc *UserController) createOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) { + if userID == "" { + return nil, &common.UserIdNotProvidedError{} } - - deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) - user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent()) + token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(ctx, userID, ttl) if err != nil { - _ = c.Error(err) - return + return nil, err } + return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil +} - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return +func (uc *UserController) requestOneTimeAccessEmailAsUnauthenticatedUserHandler(ctx context.Context, input *oneTimeAccessEmailInput) (*emptyOutputWithCookie, error) { + deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, input.Body.Email, input.Body.RedirectPath) + if err != nil { + return nil, err } + return &emptyOutputWithCookie{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil +} +func (uc *UserController) requestOneTimeAccessEmailAsAdminHandler(ctx context.Context, input *oneTimeAccessEmailAdminInput) (*httpapi.EmptyOutput, error) { + ttl := input.Body.TTL.Duration + if ttl <= 0 { + ttl = defaultOneTimeAccessTokenDuration + } + if err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(ctx, input.ID, ttl); err != nil { + return nil, err + } + return &httpapi.EmptyOutput{}, nil +} + +func (uc *UserController) exchangeOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessExchangeInput) (*userCookieOutput, error) { + if len(input.Token) != 6 && len(input.Token) != 16 { + return nil, &common.TokenInvalidOrExpiredError{} + } + deviceToken := "" + if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil { + deviceToken = requestCookie.Value + } + user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(ctx, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx)) + if err != nil { + return nil, err + } + var output dto.UserDto + if err := dto.MapStruct(user, &output); err != nil { + return nil, err + } maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, token) - - c.JSON(http.StatusOK, userDto) + return &userCookieOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil } -// updateUserGroups godoc -// @Summary Update user groups -// @Description Update the groups a specific user belongs to -// @Tags Users -// @Param id path string true "User ID" -// @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) { - var input dto.UserUpdateUserGroupDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds) +func (uc *UserController) updateUserGroups(ctx context.Context, input *userGroupsInput) (*httpapi.BodyOutput[dto.UserDto], error) { + user, err := uc.userService.UpdateUserGroups(ctx, input.ID, input.Body.UserGroupIds) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, userDto) + return mapUser(user) } -// updateUser is an internal helper method, not exposed as an API endpoint -func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { - var input dto.UserCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return +func (uc *UserController) resetUserProfilePictureHandler(ctx context.Context, input *userIDInput) (*httpapi.EmptyOutput, error) { + if err := uc.userService.ResetProfilePicture(ctx, input.ID); err != nil { + return nil, err } - - var userID string - if updateOwnUser { - userID = c.GetString("userID") - } else { - userID = c.Param("id") - } - - user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false) - if err != nil { - _ = c.Error(err) - return - } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, userDto) + return &httpapi.EmptyOutput{}, nil } -// resetUserProfilePictureHandler godoc -// @Summary Reset user profile picture -// @Description Reset a specific user's profile picture to the default -// @Tags Users -// @Produce json -// @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) { - userID := c.Param("id") - - if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return +func (uc *UserController) resetCurrentUserProfilePictureHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := uc.userService.ResetProfilePicture(ctx, httpapi.UserID(ctx)); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// resetCurrentUserProfilePictureHandler godoc -// @Summary Reset current user's profile picture -// @Description Reset the currently authenticated user's profile picture to the default -// @Tags Users -// @Produce json -// @Success 204 "No Content" -// @Router /api/users/me/profile-picture [delete] -func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) { - userID := c.GetString("userID") - - if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return +func (uc *UserController) sendEmailVerificationHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + if err := uc.userService.SendEmailVerification(ctx, httpapi.UserID(ctx)); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// sendEmailVerificationHandler godoc -// @Summary Send email verification -// @Description Send an email verification to the currently authenticated user -// @Tags Users -// @Produce json -// @Success 204 "No Content" -// @Router /api/users/me/send-email-verification [post] -func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) { - userID := c.GetString("userID") - - if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil { - _ = c.Error(err) - return +func (uc *UserController) verifyEmailHandler(ctx context.Context, input *emailVerificationInput) (*httpapi.EmptyOutput, error) { + if err := uc.userService.VerifyEmail(ctx, httpapi.UserID(ctx), input.Body.Token); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// verifyEmailHandler godoc -// @Summary Verify email -// @Description Verify the currently authenticated user's email using a verification token -// @Tags Users -// @Param body body dto.EmailVerificationDto true "Email verification token" -// @Success 204 "No Content" -// @Router /api/users/me/verify-email [post] -func (uc *UserController) verifyEmailHandler(c *gin.Context) { - var input dto.EmailVerificationDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userID := c.GetString("userID") - if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) +type emptyOutputWithCookie struct { + SetCookie []http.Cookie `header:"Set-Cookie"` +} + +func mapUser(user model.User) (*httpapi.BodyOutput[dto.UserDto], error) { + var output dto.UserDto + if err := dto.MapStruct(user, &output); err != nil { + return nil, err + } + return &httpapi.BodyOutput[dto.UserDto]{Body: output}, nil } diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index a0567752..8cbb1a5e 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -1,250 +1,161 @@ package controller import ( + "context" "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/dto" "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" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewUserGroupController creates a new controller for user group management -// @Summary User group management controller -// @Description Initializes all user group-related API endpoints -// @Tags User Groups -func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) { - ugc := UserGroupController{ - UserGroupService: userGroupService, - } +type userGroupListInput struct { + utils.ListRequestOptions + Search string `query:"search" required:"false"` +} - 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) - } +type userGroupIDInput struct { + ID string `path:"id"` +} + +type userGroupCreateInput struct { + Body dto.UserGroupCreateDto +} + +type userGroupUpdateInput struct { + ID string `path:"id"` + Body dto.UserGroupCreateDto +} + +type userGroupUsersInput struct { + ID string `path:"id"` + Body dto.UserGroupUpdateUsersDto +} + +type userGroupClientsInput struct { + ID string `path:"id"` + Body dto.UserGroupUpdateAllowedOidcClientsDto +} + +// NewUserGroupController registers user group management routes +func NewUserGroupController(api huma.API, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) { + controller := &UserGroupController{UserGroupService: userGroupService} + auth := authMiddleware.Huma(api) + + listOperation := userGroupOperation("list-user-groups", http.MethodGet, "/api/user-groups", "List user groups") + auth(&listOperation) + httpapi.Register(api, listOperation, controller.list) + + getOperation := userGroupOperation("get-user-group", http.MethodGet, "/api/user-groups/{id}", "Get user group by ID") + auth(&getOperation) + httpapi.Register(api, getOperation, controller.get) + + createOperation := userGroupOperation("create-user-group", http.MethodPost, "/api/user-groups", "Create user group") + createOperation.DefaultStatus = http.StatusCreated + auth(&createOperation) + httpapi.Register(api, createOperation, controller.create) + + updateOperation := userGroupOperation("update-user-group", http.MethodPut, "/api/user-groups/{id}", "Update user group") + auth(&updateOperation) + httpapi.Register(api, updateOperation, controller.update) + + deleteOperation := userGroupOperation("delete-user-group", http.MethodDelete, "/api/user-groups/{id}", "Delete user group") + deleteOperation.DefaultStatus = http.StatusNoContent + auth(&deleteOperation) + httpapi.Register(api, deleteOperation, controller.delete) + + usersOperation := userGroupOperation("update-user-group-users", http.MethodPut, "/api/user-groups/{id}/users", "Update users in a group") + auth(&usersOperation) + httpapi.Register(api, usersOperation, controller.updateUsers) + + clientsOperation := userGroupOperation("update-user-group-allowed-oidc-clients", http.MethodPut, "/api/user-groups/{id}/allowed-oidc-clients", "Update allowed OIDC clients") + auth(&clientsOperation) + httpapi.Register(api, clientsOperation, controller.updateAllowedOIDCClients) +} + +func userGroupOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"User Groups"}} } type UserGroupController struct { UserGroupService *service.UserGroupService } -// list godoc -// @Summary List user groups -// @Description Get a paginated list of user groups with optional search and sorting -// @Tags User Groups -// @Param search query string false "Search term to filter user groups by name" -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - searchTerm := c.Query("search") - listRequestOptions := utils.ParseListRequestOptions(c) - - groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions) +func (ugc *UserGroupController) list(ctx context.Context, input *userGroupListInput) (*httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]], error) { + groups, pagination, err := ugc.UserGroupService.List(ctx, input.Search, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - // Map the user groups to DTOs - var groupsDto = make([]dto.UserGroupMinimalDto, len(groups)) + groupsDTO := make([]dto.UserGroupMinimalDto, len(groups)) for i, group := range groups { - var groupDto dto.UserGroupMinimalDto - if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return + if err := dto.MapStruct(group, &groupsDTO[i]); err != nil { + return nil, err } - groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID) + groupsDTO[i].UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - groupsDto[i] = groupDto } - c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{ - Data: groupsDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]]{Body: dto.Paginated[dto.UserGroupMinimalDto]{Data: groupsDTO, Pagination: pagination}}, nil } -// get godoc -// @Summary Get user group by ID -// @Description Retrieve detailed information about a specific user group including its users -// @Tags User Groups -// @Accept json -// @Produce json -// @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) { - group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id")) +func (ugc *UserGroupController) get(ctx context.Context, input *userGroupIDInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + group, err := ugc.UserGroupService.Get(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var groupDto dto.UserGroupDto - if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, groupDto) + return mapUserGroup(group) } -// create godoc -// @Summary Create user group -// @Description Create a new user group -// @Tags User Groups -// @Accept json -// @Produce json -// @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) { - var input dto.UserGroupCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - group, err := ugc.UserGroupService.Create(c.Request.Context(), input) +func (ugc *UserGroupController) create(ctx context.Context, input *userGroupCreateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + group, err := ugc.UserGroupService.Create(ctx, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var groupDto dto.UserGroupDto - if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, groupDto) + return mapUserGroup(group) } -// update godoc -// @Summary Update user group -// @Description Update an existing user group by ID -// @Tags User Groups -// @Accept json -// @Produce json -// @Param id path string true "User Group ID" -// @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) { - var input dto.UserGroupCreateDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input) +func (ugc *UserGroupController) update(ctx context.Context, input *userGroupUpdateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + group, err := ugc.UserGroupService.Update(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var groupDto dto.UserGroupDto - if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, groupDto) + return mapUserGroup(group) } -// delete godoc -// @Summary Delete user group -// @Description Delete a specific user group by ID -// @Tags User Groups -// @Accept json -// @Produce json -// @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) { - if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil { - _ = c.Error(err) - return +func (ugc *UserGroupController) delete(ctx context.Context, input *userGroupIDInput) (*httpapi.EmptyOutput, error) { + if err := ugc.UserGroupService.Delete(ctx, input.ID); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// updateUsers godoc -// @Summary Update users in a group -// @Description Update the list of users belonging to a specific user group -// @Tags User Groups -// @Accept json -// @Produce json -// @Param id path string true "User Group ID" -// @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) { - var input dto.UserGroupUpdateUsersDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs) +func (ugc *UserGroupController) updateUsers(ctx context.Context, input *userGroupUsersInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + group, err := ugc.UserGroupService.UpdateUsers(ctx, input.ID, input.Body.UserIDs) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var groupDto dto.UserGroupDto - if err := dto.MapStruct(group, &groupDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, groupDto) + return mapUserGroup(group) } -// updateAllowedOidcClients godoc -// @Summary Update allowed OIDC clients -// @Description Update the OIDC clients allowed for a specific user group -// @Tags OIDC -// @Accept json -// @Produce json -// @Param id path string true "User Group ID" -// @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) { - var input dto.UserGroupUpdateAllowedOidcClientsDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input) +func (ugc *UserGroupController) updateAllowedOIDCClients(ctx context.Context, input *userGroupClientsInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + group, err := ugc.UserGroupService.UpdateAllowedOidcClient(ctx, input.ID, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userGroupDto dto.UserGroupDto - if err := dto.MapStruct(userGroup, &userGroupDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, userGroupDto) + return mapUserGroup(group) +} + +func mapUserGroup(group any) (*httpapi.BodyOutput[dto.UserGroupDto], error) { + var output dto.UserGroupDto + if err := dto.MapStruct(group, &output); err != nil { + return nil, err + } + return &httpapi.BodyOutput[dto.UserGroupDto]{Body: output}, nil } diff --git a/backend/internal/controller/version_controller.go b/backend/internal/controller/version_controller.go index 09679908..d19aec11 100644 --- a/backend/internal/controller/version_controller.go +++ b/backend/internal/controller/version_controller.go @@ -1,56 +1,64 @@ package controller import ( + "context" "net/http" "time" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/common" "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" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewVersionController registers version-related routes. -func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) { +type versionOutput struct { + CacheControl string `header:"Cache-Control"` + Body map[string]string +} + +// NewVersionController registers version-related routes +func NewVersionController(api huma.API, 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) + + httpapi.Register(api, huma.Operation{ + OperationID: "get-latest-version", + Method: http.MethodGet, + Path: "/api/version/latest", + Summary: "Get latest available version of Pocket ID", + Tags: []string{"Version"}, + }, vc.getLatestVersionHandler) + + currentOperation := huma.Operation{ + OperationID: "get-current-version", + Method: http.MethodGet, + Path: "/api/version/current", + Summary: "Get current deployed version of Pocket ID", + Tags: []string{"Version"}, + } + authMiddleware.WithAdminNotRequired().Huma(api)(¤tOperation) + httpapi.Register(api, currentOperation, vc.getCurrentVersionHandler) } type VersionController struct { versionService *service.VersionService } -// getLatestVersionHandler godoc -// @Summary Get latest available version of Pocket ID -// @Tags Version -// @Produce json -// @Success 200 {object} map[string]string "Latest version information" -// @Router /api/version/latest [get] -func (vc *VersionController) getLatestVersionHandler(c *gin.Context) { - tag, err := vc.versionService.GetLatestVersion(c.Request.Context()) +func (vc *VersionController) getLatestVersionHandler(ctx context.Context, _ *httpapi.EmptyInput) (*versionOutput, error) { + tag, err := vc.versionService.GetLatestVersion(ctx) if err != nil { - _ = c.Error(err) - return + return nil, err } - utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute) - - c.JSON(http.StatusOK, gin.H{ - "latestVersion": tag, - }) + cacheControl := "" + if !httpapi.QueryPresent(ctx, "skipCache") { + cacheControl = utils.CacheControlValue(5*time.Minute, 15*time.Minute) + } + return &versionOutput{CacheControl: cacheControl, Body: map[string]string{"latestVersion": tag}}, nil } -// getCurrentVersionHandler godoc -// @Summary Get current deployed version of Pocket ID -// @Tags Version -// @Produce json -// @Success 200 {object} map[string]string "Current version information" -// @Router /api/version/current [get] -func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) { - - c.JSON(http.StatusOK, gin.H{ - "currentVersion": common.Version, - }) +func (vc *VersionController) getCurrentVersionHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) { + return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"currentVersion": common.Version}}, nil } diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index cf8c1c84..e7b8af41 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -1,36 +1,39 @@ package controller import ( + "context" "encoding/json" "fmt" "log/slog" "net/http" "os" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) -// NewWellKnownController creates a new controller for OIDC discovery endpoints -// @Summary OIDC Discovery controller -// @Description Initializes OIDC discovery and JWKS endpoints -// @Tags Well Known -func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) { - wkc := &WellKnownController{jwtService: jwtService} +type wellKnownOutput struct { + ContentType string `header:"Content-Type"` + Body []byte +} + +// NewWellKnownController registers OIDC discovery endpoints +func NewWellKnownController(api huma.API, jwtService *service.JwtService) { + controller := &WellKnownController{jwtService: jwtService} - // Pre-compute the OIDC configuration document, which is static var err error - wkc.oidcConfig, err = wkc.computeOIDCConfiguration() + controller.oidcConfig, err = controller.computeOIDCConfiguration() if err != nil { slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err)) os.Exit(1) return } - group.GET("/.well-known/jwks.json", wkc.jwksHandler) - group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler) + httpapi.Register(api, huma.Operation{OperationID: "get-jwks", Method: http.MethodGet, Path: "/.well-known/jwks.json", Summary: "Get JSON Web Key Set", Tags: []string{"Well Known"}}, controller.jwksHandler) + httpapi.Register(api, huma.Operation{OperationID: "get-openid-configuration", Method: http.MethodGet, Path: "/.well-known/openid-configuration", Summary: "Get OpenID Connect discovery configuration", Tags: []string{"Well Known"}}, controller.openIDConfigurationHandler) } type WellKnownController struct { @@ -38,51 +41,35 @@ type WellKnownController struct { oidcConfig []byte } -// jwksHandler godoc -// @Summary Get JSON Web Key Set (JWKS) -// @Description Returns the JSON Web Key Set used for token verification -// @Tags Well Known -// @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(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) { jwks, err := wkc.jwtService.GetPublicJWKSAsJSON() if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.Data(http.StatusOK, "application/json; charset=utf-8", jwks) + return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: jwks}, nil } -// openIDConfigurationHandler godoc -// @Summary Get OpenID Connect discovery configuration -// @Description Returns the OpenID Connect discovery document with endpoints and capabilities -// @Tags Well Known -// @Success 200 {object} object "OpenID Connect configuration" -// @Router /.well-known/openid-configuration [get] -func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { - c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig) +func (wkc *WellKnownController) openIDConfigurationHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) { + return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: wkc.oidcConfig}, nil } func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { - appUrl := common.EnvConfig.AppURL - - internalAppUrl := common.EnvConfig.InternalAppURL + appURL := common.EnvConfig.AppURL + internalAppURL := common.EnvConfig.InternalAppURL alg, err := wkc.jwtService.GetKeyAlg() if err != nil { return nil, fmt.Errorf("failed to get key algorithm: %w", err) } config := map[string]any{ - "issuer": appUrl, - "authorization_endpoint": appUrl + "/authorize", - "token_endpoint": internalAppUrl + "/api/oidc/token", - "userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo", - "end_session_endpoint": appUrl + "/api/oidc/end-session", - "introspection_endpoint": internalAppUrl + "/api/oidc/introspect", - "device_authorization_endpoint": appUrl + "/api/oidc/device/authorize", - "jwks_uri": internalAppUrl + "/.well-known/jwks.json", + "issuer": appURL, + "authorization_endpoint": appURL + "/authorize", + "token_endpoint": internalAppURL + "/api/oidc/token", + "userinfo_endpoint": internalAppURL + "/api/oidc/userinfo", + "end_session_endpoint": appURL + "/api/oidc/end-session", + "introspection_endpoint": internalAppURL + "/api/oidc/introspect", + "device_authorization_endpoint": appURL + "/api/oidc/device/authorize", + "jwks_uri": internalAppURL + "/.well-known/jwks.json", "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials}, "scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"}, @@ -96,7 +83,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { "request_object_signing_alg_values_supported": []string{"none"}, "prompt_values_supported": []string{"none", "login", "consent", "select_account"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"}, - "pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par", + "pushed_authorization_request_endpoint": internalAppURL + "/api/oidc/par", "require_pushed_authorization_requests": false, } return json.Marshal(config) diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 7fbabb9a..0025bd12 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -1,5 +1,12 @@ package dto +import ( + "encoding/json" + "net/mail" + + "github.com/danielgtaylor/huma/v2" +) + type PublicAppConfigVariableDto struct { Key string `json:"key"` Type string `json:"type"` @@ -12,47 +19,64 @@ type AppConfigVariableDto struct { } type AppConfigUpdateDto struct { - AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"` - SessionDuration string `json:"sessionDuration" binding:"required"` - HomePageURL string `json:"homePageUrl" binding:"required"` - EmailsVerified string `json:"emailsVerified" binding:"required"` - DisableAnimations string `json:"disableAnimations" binding:"required"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` - AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"` - SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"` - SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"` - AccentColor string `json:"accentColor"` - RequireUserEmail string `json:"requireUserEmail" binding:"required"` - SmtpHost string `json:"smtpHost"` - SmtpPort string `json:"smtpPort"` - SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` - SmtpUser string `json:"smtpUser"` - SmtpPassword string `json:"smtpPassword"` - SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` - LdapEnabled string `json:"ldapEnabled" binding:"required"` - LdapUrl string `json:"ldapUrl"` - LdapBindDn string `json:"ldapBindDn"` - LdapBindPassword string `json:"ldapBindPassword"` - LdapBase string `json:"ldapBase"` - LdapUserSearchFilter string `json:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify string `json:"ldapSkipCertVerify"` - LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` - LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` - LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"` - LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName string `json:"ldapAttributeGroupName"` - LdapAdminGroupName string `json:"ldapAdminGroupName"` - LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` - EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"` - EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` - EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` - EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"` - EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"` + AppName string `json:"appName" required:"false" minLength:"1" maxLength:"30" unorm:"nfc"` + SessionDuration string `json:"sessionDuration" required:"false"` + HomePageURL string `json:"homePageUrl" required:"false"` + EmailsVerified string `json:"emailsVerified" required:"false"` + DisableAnimations string `json:"disableAnimations" required:"false"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" required:"false"` + AllowUserSignups string `json:"allowUserSignups" required:"false" enum:"disabled,withToken,open"` + SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" required:"false"` + SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" required:"false"` + AccentColor string `json:"accentColor" required:"false"` + RequireUserEmail string `json:"requireUserEmail" required:"false"` + SmtpHost string `json:"smtpHost" required:"false"` + SmtpPort string `json:"smtpPort" required:"false"` + SmtpFrom string `json:"smtpFrom" required:"false"` + SmtpUser string `json:"smtpUser" required:"false"` + SmtpPassword string `json:"smtpPassword" required:"false"` + SmtpTls string `json:"smtpTls" required:"false" enum:"none,starttls,tls"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify" required:"false"` + LdapEnabled string `json:"ldapEnabled" required:"false"` + LdapUrl string `json:"ldapUrl" required:"false"` + LdapBindDn string `json:"ldapBindDn" required:"false"` + LdapBindPassword string `json:"ldapBindPassword" required:"false"` + LdapBase string `json:"ldapBase" required:"false"` + LdapUserSearchFilter string `json:"ldapUserSearchFilter" required:"false"` + LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter" required:"false"` + LdapSkipCertVerify string `json:"ldapSkipCertVerify" required:"false"` + LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier" required:"false"` + LdapAttributeUserUsername string `json:"ldapAttributeUserUsername" required:"false"` + LdapAttributeUserEmail string `json:"ldapAttributeUserEmail" required:"false"` + LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName" required:"false"` + LdapAttributeUserLastName string `json:"ldapAttributeUserLastName" required:"false"` + LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName" required:"false"` + LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture" required:"false"` + LdapAttributeGroupMember string `json:"ldapAttributeGroupMember" required:"false"` + LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier" required:"false"` + LdapAttributeGroupName string `json:"ldapAttributeGroupName" required:"false"` + LdapAdminGroupName string `json:"ldapAdminGroupName" required:"false"` + LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" required:"false"` + EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" required:"false"` + EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" required:"false"` + EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" required:"false"` + EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" required:"false"` + EmailVerificationEnabled string `json:"emailVerificationEnabled" required:"false"` +} + +func (d *AppConfigUpdateDto) Resolve(huma.Context) []error { + var errs []error + if d.SmtpFrom != "" { + address, err := mail.ParseAddress(d.SmtpFrom) + if err != nil || address.Address != d.SmtpFrom { + errs = append(errs, &huma.ErrorDetail{Location: "body.smtpFrom", Message: "Field validation for 'SmtpFrom' failed on the 'email' tag"}) + } + } + if d.SignupDefaultUserGroupIDs != "" && !json.Valid([]byte(d.SignupDefaultUserGroupIDs)) { + errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultUserGroupIDs", Message: "Signup default user group IDs must be valid JSON"}) + } + if d.SignupDefaultCustomClaims != "" && !json.Valid([]byte(d.SignupDefaultCustomClaims)) { + errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultCustomClaims", Message: "Signup default custom claims must be valid JSON"}) + } + return errs } diff --git a/backend/internal/dto/custom_claim_dto.go b/backend/internal/dto/custom_claim_dto.go index 9378f74c..bbe3598a 100644 --- a/backend/internal/dto/custom_claim_dto.go +++ b/backend/internal/dto/custom_claim_dto.go @@ -6,6 +6,6 @@ type CustomClaimDto struct { } type CustomClaimCreateDto struct { - Key string `json:"key" binding:"required" unorm:"nfc"` - Value string `json:"value" binding:"required" unorm:"nfc"` + Key string `json:"key" required:"true" unorm:"nfc"` + Value string `json:"value" required:"true" unorm:"nfc"` } diff --git a/backend/internal/dto/dto_normalize.go b/backend/internal/dto/dto_normalize.go index b988e84e..96207f55 100644 --- a/backend/internal/dto/dto_normalize.go +++ b/backend/internal/dto/dto_normalize.go @@ -3,8 +3,6 @@ package dto import ( "reflect" - "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" "golang.org/x/text/unicode/norm" ) @@ -68,7 +66,3 @@ loop: fv.SetString(val) } } - -func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error { - return ctx.ShouldBindWith(obj, binding.JSON) -} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index d87716b0..00e5d724 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -1,6 +1,9 @@ package dto -import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +import ( + "github.com/danielgtaylor/huma/v2" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) type OidcClientMetaDataDto struct { ID string `json:"id"` @@ -36,27 +39,27 @@ type OidcClientWithAllowedGroupsCountDto struct { } type OidcClientUpdateDto struct { - Name string `json:"name" binding:"required,max=50" unorm:"nfc"` - Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"` - CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"` - LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"` - IsPublic bool `json:"isPublic"` - PkceEnabled bool `json:"pkceEnabled"` - RequiresReauthentication bool `json:"requiresReauthentication"` - RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"` - SkipConsent bool `json:"skipConsent"` - Credentials OidcClientCredentialsDto `json:"credentials"` - LaunchURL *string `json:"launchURL" binding:"omitempty,url"` - HasLogo bool `json:"hasLogo"` - HasDarkLogo bool `json:"hasDarkLogo"` - LogoURL *string `json:"logoUrl"` - DarkLogoURL *string `json:"darkLogoUrl"` - IsGroupRestricted bool `json:"isGroupRestricted"` + Name string `json:"name" required:"true" maxLength:"50" unorm:"nfc"` + Description string `json:"description" required:"false" maxLength:"150" unorm:"nfc"` + CallbackURLs []string `json:"callbackURLs" required:"false"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs" required:"false"` + IsPublic bool `json:"isPublic" required:"false"` + PkceEnabled bool `json:"pkceEnabled" required:"false"` + RequiresReauthentication bool `json:"requiresReauthentication" required:"false"` + RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests" required:"false"` + SkipConsent bool `json:"skipConsent" required:"false"` + Credentials OidcClientCredentialsDto `json:"credentials" required:"false"` + LaunchURL *string `json:"launchURL" required:"false" format:"uri"` + HasLogo bool `json:"hasLogo" required:"false"` + HasDarkLogo bool `json:"hasDarkLogo" required:"false"` + LogoURL *string `json:"logoUrl" required:"false"` + DarkLogoURL *string `json:"darkLogoUrl" required:"false"` + IsGroupRestricted bool `json:"isGroupRestricted" required:"false"` } type OidcClientCreateDto struct { OidcClientUpdateDto - ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"` + ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"` } type OidcClientCredentialsDto struct { @@ -64,15 +67,42 @@ type OidcClientCredentialsDto struct { } type OidcClientFederatedIdentityDto struct { - Issuer string `json:"issuer"` + Issuer string `json:"issuer" required:"false"` Subject string `json:"subject,omitempty"` Audience string `json:"audience,omitempty"` JWKS string `json:"jwks,omitempty"` - ReplayProtection bool `json:"replayProtection"` + ReplayProtection bool `json:"replayProtection" required:"false"` } type OidcUpdateAllowedUserGroupsDto struct { - UserGroupIDs []string `json:"userGroupIds" binding:"required"` + UserGroupIDs []string `json:"userGroupIds" required:"true"` +} + +func (d *OidcClientUpdateDto) Resolve(huma.Context) []error { + return validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs) +} + +func (d *OidcClientCreateDto) Resolve(huma.Context) []error { + errs := validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs) + if d.ID != "" && !ValidateClientID(d.ID) { + errs = append(errs, &huma.ErrorDetail{Location: "body.id", Message: "Client ID is invalid"}) + } + return errs +} + +func validateCallbackURLLists(callbackURLs, logoutCallbackURLs []string) []error { + var errs []error + for _, callbackURL := range callbackURLs { + if !ValidateCallbackURLPattern(callbackURL) { + errs = append(errs, &huma.ErrorDetail{Location: "body.callbackURLs", Message: "Callback URL pattern is invalid"}) + } + } + for _, callbackURL := range logoutCallbackURLs { + if !ValidateCallbackURLPattern(callbackURL) { + errs = append(errs, &huma.ErrorDetail{Location: "body.logoutCallbackURLs", Message: "Logout callback URL pattern is invalid"}) + } + } + return errs } type OidcLogoutDto struct { diff --git a/backend/internal/dto/one_time_access_dto.go b/backend/internal/dto/one_time_access_dto.go index a99dc5ac..c1ff9205 100644 --- a/backend/internal/dto/one_time_access_dto.go +++ b/backend/internal/dto/one_time_access_dto.go @@ -1,16 +1,34 @@ package dto -import "github.com/pocket-id/pocket-id/backend/internal/utils" +import ( + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) type OneTimeAccessTokenCreateDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"ttl"` + TTL utils.JSONDuration `json:"ttl" required:"false"` } type OneTimeAccessEmailAsUnauthenticatedUserDto struct { - Email string `json:"email" binding:"required,email" unorm:"nfc"` - RedirectPath string `json:"redirectPath"` + Email string `json:"email" required:"true" format:"email" unorm:"nfc"` + RedirectPath string `json:"redirectPath" required:"false"` } type OneTimeAccessEmailAsAdminDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"ttl"` + TTL utils.JSONDuration `json:"ttl" required:"false"` +} + +func (d *OneTimeAccessTokenCreateDto) Resolve(huma.Context) []error { + return resolveTTL(d.TTL) +} + +func (d *OneTimeAccessEmailAsAdminDto) Resolve(huma.Context) []error { + return resolveTTL(d.TTL) +} + +func resolveTTL(ttl utils.JSONDuration) []error { + if ValidateTTL(ttl) { + return nil + } + return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}} } diff --git a/backend/internal/dto/scim_dto.go b/backend/internal/dto/scim_dto.go index c04c8948..52e32e39 100644 --- a/backend/internal/dto/scim_dto.go +++ b/backend/internal/dto/scim_dto.go @@ -16,9 +16,9 @@ type ScimServiceProviderDTO struct { } type ScimServiceProviderCreateDTO struct { - Endpoint string `json:"endpoint" binding:"required,url"` - Token string `json:"token"` - OidcClientID string `json:"oidcClientId" binding:"required"` + Endpoint string `json:"endpoint" required:"true" format:"uri"` + Token string `json:"token" required:"false"` + OidcClientID string `json:"oidcClientId" required:"true"` } type ScimUser struct { diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index e337528f..a4e3bc9f 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -2,8 +2,8 @@ package dto import ( "errors" - - "github.com/gin-gonic/gin/binding" + "net/mail" + "unicode/utf8" ) type UserDto struct { @@ -23,34 +23,52 @@ type UserDto struct { } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - EmailVerified bool `json:"emailVerified"` - FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - Disabled bool `json:"disabled"` - UserGroupIds []string `json:"userGroupIds"` + Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"` + Email *string `json:"email" required:"false" format:"email" unorm:"nfc"` + EmailVerified bool `json:"emailVerified" required:"false"` + FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"` + LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"` + DisplayName string `json:"displayName" required:"false" maxLength:"100" unorm:"nfc"` + IsAdmin bool `json:"isAdmin" required:"false"` + Locale *string `json:"locale" required:"false"` + Disabled bool `json:"disabled" required:"false"` + UserGroupIds []string `json:"userGroupIds" required:"false"` LdapID string `json:"-"` } +//nolint:staticcheck // LDAP callers and their tests rely on the existing capitalized validation text func (u UserCreateDto) Validate() error { - e, ok := binding.Validator.Engine().(interface { - Struct(s any) error - }) - if !ok { - return errors.New("validator does not implement the expected interface") + if u.Username == "" { + return errors.New("Field validation for 'Username' failed on the 'required' tag") } - - return e.Struct(u) + if !ValidateUsername(u.Username) { + return errors.New("Field validation for 'Username' failed on the 'username' tag") + } + if utf8.RuneCountInString(u.Username) > 50 { + return errors.New("Field validation for 'Username' failed on the 'max' tag") + } + if u.Email != nil { + address, err := mail.ParseAddress(*u.Email) + if err != nil || address.Address != *u.Email { + return errors.New("Field validation for 'Email' failed on the 'email' tag") + } + } + if utf8.RuneCountInString(u.FirstName) > 50 { + return errors.New("Field validation for 'FirstName' failed on the 'max' tag") + } + if utf8.RuneCountInString(u.LastName) > 50 { + return errors.New("Field validation for 'LastName' failed on the 'max' tag") + } + if utf8.RuneCountInString(u.DisplayName) > 100 { + return errors.New("Field validation for 'DisplayName' failed on the 'max' tag") + } + return nil } type EmailVerificationDto struct { - Token string `json:"token" binding:"required"` + Token string `json:"token" required:"true"` } type UserUpdateUserGroupDto struct { - UserGroupIds []string `json:"userGroupIds" binding:"required"` + UserGroupIds []string `json:"userGroupIds" required:"true"` } diff --git a/backend/internal/dto/user_group_dto.go b/backend/internal/dto/user_group_dto.go index 79cd8d48..462cd34d 100644 --- a/backend/internal/dto/user_group_dto.go +++ b/backend/internal/dto/user_group_dto.go @@ -2,8 +2,8 @@ package dto import ( "errors" + "unicode/utf8" - "github.com/gin-gonic/gin/binding" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" ) @@ -29,26 +29,27 @@ type UserGroupMinimalDto struct { } type UserGroupUpdateAllowedOidcClientsDto struct { - OidcClientIDs []string `json:"oidcClientIds" binding:"required"` + OidcClientIDs []string `json:"oidcClientIds" required:"true"` } type UserGroupCreateDto struct { - FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` - Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` + FriendlyName string `json:"friendlyName" required:"true" minLength:"2" maxLength:"50" unorm:"nfc"` + Name string `json:"name" required:"true" minLength:"2" maxLength:"255" unorm:"nfc"` LdapID string `json:"-"` } func (g UserGroupCreateDto) Validate() error { - e, ok := binding.Validator.Engine().(interface { - Struct(s any) error - }) - if !ok { - return errors.New("validator does not implement the expected interface") + friendlyNameLength := utf8.RuneCountInString(g.FriendlyName) + if friendlyNameLength < 2 || friendlyNameLength > 50 { + return errors.New("friendly name is invalid") } - - return e.Struct(g) + nameLength := utf8.RuneCountInString(g.Name) + if nameLength < 2 || nameLength > 255 { + return errors.New("name is invalid") + } + return nil } type UserGroupUpdateUsersDto struct { - UserIDs []string `json:"userIds" binding:"required"` + UserIDs []string `json:"userIds" required:"true"` } diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 0f4ab9f6..a49944a8 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -8,9 +8,6 @@ import ( "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/utils" - - "github.com/gin-gonic/gin/binding" - "github.com/go-playground/validator/v10" ) // [a-zA-Z0-9] : The username must start with an alphanumeric character @@ -21,44 +18,7 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a- var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") -func init() { - engine := binding.Validator.Engine().(*validator.Validate) - - // Maximum allowed value for TTLs - const maxTTL = 31 * 24 * time.Hour - - validators := map[string]validator.Func{ - "username": func(fl validator.FieldLevel) bool { - return ValidateUsername(fl.Field().String()) - }, - "client_id": func(fl validator.FieldLevel) bool { - return ValidateClientID(fl.Field().String()) - }, - "ttl": func(fl validator.FieldLevel) bool { - ttl, ok := fl.Field().Interface().(utils.JSONDuration) - if !ok { - return false - } - // Allow zero, which means the field wasn't set - return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL) - }, - "callback_url": func(fl validator.FieldLevel) bool { - return ValidateCallbackURL(fl.Field().String()) - }, - "callback_url_pattern": func(fl validator.FieldLevel) bool { - return ValidateCallbackURLPattern(fl.Field().String()) - }, - "resource_uri": func(fl validator.FieldLevel) bool { - return ValidateResourceURI(fl.Field().String()) - }, - } - for k, v := range validators { - err := engine.RegisterValidation(k, v) - if err != nil { - panic("Failed to register custom validation for " + k + ": " + err.Error()) - } - } -} +const maxTTL = 31 * 24 * time.Hour // ValidateUsername validates username inputs func ValidateUsername(username string) bool { @@ -70,6 +30,11 @@ func ValidateClientID(clientID string) bool { return validateClientIDRegex.MatchString(clientID) } +// ValidateTTL validates optional API durations against the existing bounds +func ValidateTTL(ttl utils.JSONDuration) bool { + return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL) +} + // isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link func isActiveContentScheme(scheme string) bool { switch strings.ToLower(scheme) { diff --git a/backend/internal/dto/webauthn_dto.go b/backend/internal/dto/webauthn_dto.go index e1383f92..a4d8c7be 100644 --- a/backend/internal/dto/webauthn_dto.go +++ b/backend/internal/dto/webauthn_dto.go @@ -10,7 +10,7 @@ type WebauthnCredentialDto struct { Name string `json:"name"` CredentialID string `json:"credentialID"` AttestationType string `json:"attestationType"` - Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"` + Transport []protocol.AuthenticatorTransport `json:"transport"` BackupEligible bool `json:"backupEligible"` BackupState bool `json:"backupState"` @@ -19,5 +19,5 @@ type WebauthnCredentialDto struct { } type WebauthnCredentialUpdateDto struct { - Name string `json:"name" binding:"required,min=1,max=50"` + Name string `json:"name" required:"true" minLength:"1" maxLength:"50"` } diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index 62b89897..6d327866 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -2,11 +2,15 @@ package middleware import ( "errors" + "time" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" "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/service" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) // AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication @@ -62,7 +66,7 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware { return clone } -// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth. +// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware { clone := &AuthMiddleware{ apiKeyMiddleware: m.apiKeyMiddleware, @@ -75,60 +79,103 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware { func (m *AuthMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { - userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) - if err == nil { - c.Set("userID", userID) - c.Set("userIsAdmin", isAdmin) - c.Set("authenticationMethod", authenticationMethod) - c.Set("authenticationTime", authenticationTime) - if c.IsAborted() { - return - } - c.Next() - return - } - - // If JWT auth failed and the error is not a NotSignedInError, abort the request - if !errors.Is(err, &common.NotSignedInError{}) { + result, err := m.authenticate(c) + if err != nil { c.Abort() _ = c.Error(err) return } - - if !m.options.AllowApiKeyAuth { - if m.options.SuccessOptional { - c.Next() - return - } - - c.Abort() - if c.GetHeader("X-API-Key") != "" { - _ = c.Error(&common.APIKeyAuthNotAllowedError{}) - return - } - _ = c.Error(err) - return + if result.UserID != "" { + setGinAuthentication(c, result) } - - // JWT auth failed, try API key auth - userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) - if err == nil { - c.Set("userID", userID) - c.Set("userIsAdmin", isAdmin) - if c.IsAborted() { - return - } - c.Next() - return - } - - if m.options.SuccessOptional { - c.Next() - return - } - - // Both JWT and API key auth failed - c.Abort() - _ = c.Error(err) + c.Next() } } + +// Huma returns an operation decorator using the same authentication behavior as Add +func (m *AuthMiddleware) Huma(api huma.API) func(*huma.Operation) { + return func(operation *huma.Operation) { + operation.Security = m.securityRequirements() + if m.options.AdminRequired { + if operation.Extensions == nil { + operation.Extensions = map[string]any{} + } + operation.Extensions["x-pocket-id-admin-required"] = true + } + operation.Middlewares = append(operation.Middlewares, func(ctx huma.Context, next func(huma.Context)) { + result, err := m.authenticate(humagin.Unwrap(ctx)) + if err != nil { + status := 500 + message := "Something went wrong" + var appError common.AppError + if errors.As(err, &appError) { + status = appError.HttpStatusCode() + message = appError.Error() + } + _ = huma.WriteErr(api, ctx, status, message) + return + } + if result.UserID != "" { + ctx = httpapi.WithAuthentication(ctx, result.UserID, result.IsAdmin, result.AuthenticationMethod, result.AuthenticationTime) + } + next(ctx) + }) + } +} + +type authenticationResult struct { + UserID string + IsAdmin bool + AuthenticationMethod string + AuthenticationTime time.Time +} + +func (m *AuthMiddleware) authenticate(c *gin.Context) (authenticationResult, error) { + userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) + if err == nil { + return authenticationResult{userID, isAdmin, authenticationMethod, authenticationTime}, nil + } + if !errors.Is(err, &common.NotSignedInError{}) { + return authenticationResult{}, err + } + + if !m.options.AllowApiKeyAuth { + if m.options.SuccessOptional { + return authenticationResult{}, nil + } + if c.GetHeader("X-API-Key") != "" { + return authenticationResult{}, &common.APIKeyAuthNotAllowedError{} + } + return authenticationResult{}, err + } + + userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) + if err == nil { + return authenticationResult{UserID: userID, IsAdmin: isAdmin}, nil + } + if m.options.SuccessOptional { + return authenticationResult{}, nil + } + return authenticationResult{}, err +} + +func (m *AuthMiddleware) securityRequirements() []map[string][]string { + requirements := []map[string][]string{ + {"BearerAuth": {}}, + {"SessionCookie": {}}, + } + if m.options.AllowApiKeyAuth { + requirements = append(requirements, map[string][]string{"ApiKeyAuth": {}}) + } + if m.options.SuccessOptional { + requirements = append([]map[string][]string{{}}, requirements...) + } + return requirements +} + +func setGinAuthentication(c *gin.Context, result authenticationResult) { + c.Set("userID", result.UserID) + c.Set("userIsAdmin", result.IsAdmin) + c.Set("authenticationMethod", result.AuthenticationMethod) + c.Set("authenticationTime", result.AuthenticationTime) +} diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index acdb344b..5f9b6d2c 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -1,12 +1,14 @@ package middleware import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" + "github.com/danielgtaylor/huma/v2" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -17,6 +19,7 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -87,6 +90,41 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { require.Equal(t, http.StatusNoContent, recorder.Code) }) + + t.Run("Huma decorator preserves JWT-only behavior and documentation", func(t *testing.T) { + humaRouter := gin.New() + api := httpapi.New(humaRouter, humaRouter.Group("/")) + operation := huma.Operation{OperationID: "huma-protected", Method: http.MethodGet, Path: "/api/huma-protected"} + authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api)(&operation) + require.Equal(t, []map[string][]string{{"BearerAuth": {}}, {"SessionCookie": {}}}, operation.Security) + httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil }) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil) + request.Header.Set("X-API-Key", apiKeyToken) + response := httptest.NewRecorder() + humaRouter.ServeHTTP(response, request) + require.Equal(t, http.StatusForbidden, response.Code) + require.JSONEq(t, `{"error":"API key authentication is not allowed for this endpoint"}`, response.Body.String()) + + request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil) + request.Header.Set("Authorization", "Bearer "+jwtToken) + response = httptest.NewRecorder() + humaRouter.ServeHTTP(response, request) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Huma admin decorator records admin authorization separately from security scopes", func(t *testing.T) { + humaRouter := gin.New() + api := httpapi.New(humaRouter, humaRouter.Group("/")) + operation := huma.Operation{} + authMiddleware.Huma(api)(&operation) + require.Equal(t, true, operation.Extensions["x-pocket-id-admin-required"]) + for _, requirement := range operation.Security { + for _, scopes := range requirement { + require.Empty(t, scopes) + } + } + }) } func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User { diff --git a/backend/internal/middleware/error_handler.go b/backend/internal/middleware/error_handler.go index 9c34b6af..73bb445d 100644 --- a/backend/internal/middleware/error_handler.go +++ b/backend/internal/middleware/error_handler.go @@ -2,13 +2,10 @@ package middleware import ( "errors" - "fmt" "net/http" "strings" "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" ) @@ -29,24 +26,6 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc { 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 { @@ -89,37 +68,3 @@ func errorResponseWithDescription(c *gin.Context, statusCode int, message string 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) - } - - errorMessages = append(errorMessages, errorMessage) - } - - // Join all the error messages into a single string - combinedErrors := strings.Join(errorMessages, ", ") - - return combinedErrors -} diff --git a/backend/internal/middleware/file_size_limit.go b/backend/internal/middleware/file_size_limit.go index c5581ac1..bf1fe7b6 100644 --- a/backend/internal/middleware/file_size_limit.go +++ b/backend/internal/middleware/file_size_limit.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" ) @@ -14,6 +16,20 @@ func NewFileSizeLimitMiddleware() *FileSizeLimitMiddleware { return &FileSizeLimitMiddleware{} } +// Huma returns a multipart size-limit middleware that preserves the existing error message +func (m *FileSizeLimitMiddleware) Huma(api huma.API, maxSize int64) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + ginCtx := humagin.Unwrap(ctx) + ginCtx.Request.Body = http.MaxBytesReader(ginCtx.Writer, ginCtx.Request.Body, maxSize) + if err := ginCtx.Request.ParseMultipartForm(maxSize); err != nil { + fileError := &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)} + _ = huma.WriteErr(api, ctx, fileError.HttpStatusCode(), fileError.Error()) + return + } + next(ctx) + } +} + func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc { return func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) 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..760bf213 --- /dev/null +++ b/backend/internal/middleware/file_size_limit_test.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" +) + +func TestHumaFileSizeLimitMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + api := httpapi.New(router, router.Group("/")) + operation := huma.Operation{OperationID: "multipart-overflow", Method: http.MethodPost, Path: "/api/upload"} + operation.Middlewares = append(operation.Middlewares, NewFileSizeLimitMiddleware().Huma(api, 64)) + type uploadInput struct { + RawBody huma.MultipartFormFiles[struct { + File huma.FormFile `form:"file" required:"true"` + }] + } + httpapi.Register(api, operation, func(context.Context, *uploadInput) (*struct{}, error) { + t.Fatal("handler must not run after multipart overflow") + return nil, nil + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "large.bin") + require.NoError(t, err) + _, err = part.Write(bytes.Repeat([]byte("x"), 256)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/upload", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusRequestEntityTooLarge, response.Code) + require.JSONEq(t, `{"error":"The file can't be larger than 64 bytes"}`, response.Body.String()) +} diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index 7357908d..3b651c51 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -60,7 +60,6 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject subject, ok := token.Subject() if !ok { - _ = c.Error(&common.TokenInvalidError{}) return "", false, "", time.Time{}, &common.TokenInvalidError{} } diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index dbfa49ad..669858f2 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" "github.com/gin-gonic/gin" "github.com/italypaleale/francis/builtin/ratelimit" @@ -86,22 +88,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc { return func(c *gin.Context) { ip := c.ClientIP() - - // Skip rate limiting for localhost and test environment - // If the client ip is localhost the request comes from the frontend - if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() { - c.Next() - return - } - - // Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now - allowed, retryAfter, err := svc.Allow(c.Request.Context(), ip) + allowed, retryAfter, err := allowRequest(c.Request.Context(), svc, policy, ip) if err != nil { - // Fail open so a limiter error does not turn away otherwise-valid traffic - if !errors.Is(err, context.Canceled) { - // A cancelled context just means the client went away, so it is not worth logging - slog.WarnContext(c.Request.Context(), "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err)) - } c.Next() return } @@ -119,3 +107,53 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc { c.Next() } } + +// Huma returns a Huma middleware backed by the existing rate-limit service +func (m *RateLimitMiddleware) Huma(api huma.API, policy string) func(huma.Context, func(huma.Context)) { + if common.EnvConfig.DisableRateLimiting { + return func(ctx huma.Context, next func(huma.Context)) { next(ctx) } + } + + svc := m.services[policy] + return func(ctx huma.Context, next func(huma.Context)) { + if svc == nil { + _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Something went wrong") + return + } + + ginCtx := humagin.Unwrap(ctx) + allowed, retryAfter, err := allowRequest(ctx.Context(), svc, policy, ginCtx.ClientIP()) + if err != nil { + next(ctx) + return + } + if !allowed { + if retryAfter > 0 { + ctx.SetHeader("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds())))) + } + _ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, (&common.TooManyRequestsError{}).Error()) + return + } + next(ctx) + } +} + +func allowRequest(ctx context.Context, svc *ratelimit.RateLimitService, policy, ip string) (bool, time.Duration, error) { + // Skip rate limiting for localhost and test environment + // If the client ip is localhost the request comes from the frontend + if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() { + return true, 0, nil + } + + // Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now + allowed, retryAfter, err := svc.Allow(ctx, ip) + if err != nil { + // Fail open so a limiter error does not turn away otherwise-valid traffic + if !errors.Is(err, context.Canceled) { + // A cancelled context just means the client went away, so it is not worth logging + slog.WarnContext(ctx, "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err)) + } + return true, 0, err + } + return allowed, retryAfter, nil +} diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go index eba3885e..6c52b1d9 100644 --- a/backend/internal/middleware/rate_limit_test.go +++ b/backend/internal/middleware/rate_limit_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/danielgtaylor/huma/v2" "github.com/gin-gonic/gin" "github.com/italypaleale/francis/builtin/ratelimit" "github.com/italypaleale/francis/host/local" @@ -15,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/common" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -145,3 +147,33 @@ func TestRateLimitMiddleware(t *testing.T) { require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code) }) } + +func TestHumaRateLimitMiddleware(t *testing.T) { + originalEnvConfig := common.EnvConfig + t.Cleanup(func() { common.EnvConfig = originalEnvConfig }) + common.EnvConfig.AppEnv = common.AppEnvProduction + common.EnvConfig.DisableRateLimiting = false + + const policy = "huma-test-limit" + services := startRateLimitServices(t, RateLimitPolicy{Name: policy, Rate: 1, Per: time.Hour, Burst: 1}) + router := gin.New() + require.NoError(t, router.SetTrustedProxies(nil)) + api := httpapi.New(router, router.Group("/")) + operation := huma.Operation{OperationID: "huma-rate-limit", Method: http.MethodGet, Path: "/api/rate-limit"} + operation.Middlewares = append(operation.Middlewares, NewRateLimitMiddleware(services).Huma(api, policy)) + httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil }) + + request := func() *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/rate-limit", nil) + req.RemoteAddr = net.JoinHostPort("203.0.113.10", "12345") + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response + } + + require.Equal(t, http.StatusNoContent, request().Code) + response := request() + require.Equal(t, http.StatusTooManyRequests, response.Code) + require.NotEmpty(t, response.Header().Get("Retry-After")) + require.JSONEq(t, `{"error":"Too many requests"}`, response.Body.String()) +} diff --git a/backend/internal/model/types/date_time.go b/backend/internal/model/types/date_time.go index bcc5f9f5..69363353 100644 --- a/backend/internal/model/types/date_time.go +++ b/backend/internal/model/types/date_time.go @@ -5,12 +5,18 @@ import ( "fmt" "time" + "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/common" ) // DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres type DateTime time.Time //nolint:recvcheck +// Schema documents the JSON representation rather than the underlying time.Time structure +func (date DateTime) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{Type: huma.TypeString, Format: "date-time"} +} + func DateTimeFromString(str string) (DateTime, error) { t, err := time.Parse(time.RFC3339Nano, str) if err != nil { diff --git a/backend/internal/oidc/authorization_handler.go b/backend/internal/oidc/authorization_handler.go index 4d11be49..dc2c004d 100644 --- a/backend/internal/oidc/authorization_handler.go +++ b/backend/internal/oidc/authorization_handler.go @@ -10,9 +10,9 @@ 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/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:" @@ -106,37 +106,33 @@ func (h *authorizationHandler) authorize(c *gin.Context) { h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response) } -func (h *authorizationHandler) getInteractionSession(c *gin.Context) { - interactionID := c.Param("id") - - interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, interactionSession) +type interactionIDInput struct { + ID string `path:"id"` } -func (h *authorizationHandler) completeInteraction(c *gin.Context) { - interactionID := c.Param("id") - authenticationTime, _ := c.Get("authenticationTime") - typedAuthenticationTime, _ := authenticationTime.(time.Time) +type completeInteractionInput struct { + ID string `path:"id"` + Body completeInteractionRequest +} - var request completeInteractionRequest - if err := c.ShouldBindJSON(&request); err != nil { - _ = c.Error(&common.ValidationError{Message: "invalid interaction request"}) - return - } - - reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) - response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c)) +func (h *authorizationHandler) getInteractionSession(ctx context.Context, input *interactionIDInput) (*httpapi.BodyOutput[interactionSessionForUser], error) { + interactionSession, err := h.authorizationService.getInteractionSession(ctx, input.ID) if err != nil { - _ = c.Error(err) - return + return nil, err } + return &httpapi.BodyOutput[interactionSessionForUser]{Body: interactionSession}, nil +} - c.JSON(http.StatusOK, response) +func (h *authorizationHandler) completeInteraction(ctx context.Context, input *completeInteractionInput) (*httpapi.BodyOutput[completeInteractionResponse], error) { + reauthenticationToken := "" + if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil { + reauthenticationToken = requestCookie.Value + } + response, err := h.authorizationService.completeInteractionStep(ctx, input.ID, httpapi.UserID(ctx), input.Body.Step, reauthenticationToken, httpapi.AuthenticationTime(ctx), requestMetaFromContext(ctx)) + if err != nil { + return nil, err + } + return &httpapi.BodyOutput[completeInteractionResponse]{Body: response}, nil } func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) { @@ -169,6 +165,10 @@ func requestMetaFromGin(c *gin.Context) requestMeta { } } +func requestMetaFromContext(ctx context.Context) requestMeta { + return requestMeta{IPAddress: httpapi.ClientIP(ctx), UserAgent: httpapi.UserAgent(ctx)} +} + func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string { params := make(map[string]string) for key, values := range requester.GetRequestForm() { diff --git a/backend/internal/oidc/device_handler.go b/backend/internal/oidc/device_handler.go index 38ac6620..0ac0e9ed 100644 --- a/backend/internal/oidc/device_handler.go +++ b/backend/internal/oidc/device_handler.go @@ -1,15 +1,17 @@ package oidc import ( + "context" "errors" "log/slog" "net/http" - "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/dto" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) type deviceHandler struct { @@ -38,50 +40,38 @@ func (h *deviceHandler) authorizeDevice(c *gin.Context) { c.JSON(http.StatusOK, response) } -func (h *deviceHandler) verifyDeviceCode(c *gin.Context) { - authenticationTime, _ := c.Get("authenticationTime") - typedAuthenticationTime, _ := authenticationTime.(time.Time) - reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName) +type deviceCodeInput struct { + Code string `query:"code" required:"true"` +} - userCode := c.Query("code") - if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) - return +func (h *deviceHandler) verifyDeviceCode(ctx context.Context, input *deviceCodeInput) (*httpapi.EmptyOutput, error) { + reauthenticationToken := "" + if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil { + reauthenticationToken = requestCookie.Value } err := h.deviceService.acceptDeviceCode( - c.Request.Context(), - userCode, - c.GetString("userID"), - c.GetString("authenticationMethod"), - typedAuthenticationTime, + ctx, + input.Code, + httpapi.UserID(ctx), + httpapi.AuthenticationMethod(ctx), + httpapi.AuthenticationTime(ctx), reauthenticationToken, - requestMetaFromGin(c), + requestMetaFromContext(ctx), ) if err != nil { if errors.Is(err, fosite.ErrAccessDenied) { - c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."}) - return + return nil, &common.OidcAccessDeniedError{} } - _ = c.Error(err) - return + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -func (h *deviceHandler) deviceCodeInfo(c *gin.Context) { - userCode := c.Query("code") - if userCode == "" { - _ = c.Error(&common.ValidationError{Message: "code is required"}) - return - } - - deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID")) +func (h *deviceHandler) deviceCodeInfo(ctx context.Context, input *deviceCodeInput) (*httpapi.BodyOutput[dto.DeviceCodeInfoDto], error) { + deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(ctx, input.Code, httpapi.UserID(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.JSON(http.StatusOK, deviceCodeInfo) + return &httpapi.BodyOutput[dto.DeviceCodeInfoDto]{Body: *deviceCodeInfo}, nil } diff --git a/backend/internal/oidc/end_session_handler.go b/backend/internal/oidc/end_session_handler.go index 185378a7..9f772bf7 100644 --- a/backend/internal/oidc/end_session_handler.go +++ b/backend/internal/oidc/end_session_handler.go @@ -35,7 +35,7 @@ func (h *endSessionHandler) endSession(c *gin.Context) { return } - cookie.AddAccessTokenCookie(c, 0, "") + http.SetCookie(c.Writer, cookie.NewAccessTokenCookie(0, "")) if callbackURL == "" { c.Redirect(http.StatusFound, h.baseURL+"/logout") return diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go index 7ba3688c..ec736771 100644 --- a/backend/internal/oidc/module.go +++ b/backend/internal/oidc/module.go @@ -6,9 +6,11 @@ import ( "net/http" "time" + "github.com/danielgtaylor/huma/v2" "github.com/gin-gonic/gin" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/pocket-id/pocket-id/backend/internal/model" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" "gorm.io/gorm" ) @@ -97,26 +99,46 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) { }, nil } -func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) { +// RegisterRawRoutes mounts protocol endpoints that must retain direct Gin and Fosite response control +func (m *Module) RegisterRawRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, api huma.API) { rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize) rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize) - apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession) - apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction) - apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest) - apiGroup.POST("/oidc/token", m.tokenHandler.token) - apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo) apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo) - apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken) - apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession) apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession) - apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice) - apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode) - apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo) + + tags := []string{"OIDC Protocol"} + httpapi.AddRawOperation(api, "authorize-get", http.MethodGet, "/authorize", "Authorize", tags, nil, http.StatusOK, http.StatusFound) + httpapi.AddRawOperation(api, "authorize-post", http.MethodPost, "/authorize", "Authorize", tags, nil, http.StatusOK, http.StatusFound) + httpapi.AddRawOperation(api, "pushed-authorization-request", http.MethodPost, "/api/oidc/par", "Create pushed authorization request", tags, []map[string][]string{{"OIDCClientBasic": {}}}) + httpapi.AddRawOperation(api, "oidc-token", http.MethodPost, "/api/oidc/token", "Exchange an OIDC token", tags, []map[string][]string{{"OIDCClientBasic": {}}}) + httpapi.AddRawOperation(api, "oidc-userinfo-get", http.MethodGet, "/api/oidc/userinfo", "Get OIDC user info", tags, []map[string][]string{{"OIDCAccessToken": {}}}) + httpapi.AddRawOperation(api, "oidc-userinfo-post", http.MethodPost, "/api/oidc/userinfo", "Get OIDC user info", tags, []map[string][]string{{"OIDCAccessToken": {}}}) + httpapi.AddRawOperation(api, "oidc-introspection", http.MethodPost, "/api/oidc/introspect", "Introspect an OIDC token", tags, []map[string][]string{{"OIDCClientBasic": {}}}) + httpapi.AddRawOperation(api, "oidc-end-session-get", http.MethodGet, "/api/oidc/end-session", "End an OIDC session", tags, nil, http.StatusFound) + httpapi.AddRawOperation(api, "oidc-end-session-post", http.MethodPost, "/api/oidc/end-session", "End an OIDC session", tags, nil, http.StatusFound) + httpapi.AddRawOperation(api, "oidc-device-authorization", http.MethodPost, "/api/oidc/device/authorize", "Create device authorization", tags, []map[string][]string{{"OIDCClientBasic": {}}}) +} + +// RegisterTypedRoutes mounts JSON interaction and device verification endpoints +func (m *Module) RegisterTypedRoutes(api huma.API, browserAuth func(*huma.Operation)) { + httpapi.Register(api, huma.Operation{OperationID: "get-oidc-interaction", Method: http.MethodGet, Path: "/api/oidc/interactions/{id}", Summary: "Get OIDC interaction", Tags: []string{"OIDC Interactions"}}, m.authorizationHandler.getInteractionSession) + + completeInteraction := huma.Operation{OperationID: "complete-oidc-interaction", Method: http.MethodPost, Path: "/api/oidc/interactions/{id}/complete", Summary: "Complete OIDC interaction", Tags: []string{"OIDC Interactions"}} + browserAuth(&completeInteraction) + httpapi.Register(api, completeInteraction, m.authorizationHandler.completeInteraction) + + verifyDevice := huma.Operation{OperationID: "verify-oidc-device-code", Method: http.MethodPost, Path: "/api/oidc/device/verify", Summary: "Verify OIDC device code", Tags: []string{"OIDC Protocol"}, DefaultStatus: http.StatusNoContent} + browserAuth(&verifyDevice) + httpapi.Register(api, verifyDevice, m.deviceHandler.verifyDeviceCode) + + deviceInfo := huma.Operation{OperationID: "get-oidc-device-info", Method: http.MethodGet, Path: "/api/oidc/device/info", Summary: "Get OIDC device code info", Tags: []string{"OIDC Protocol"}} + browserAuth(&deviceInfo) + httpapi.Register(api, deviceInfo, m.deviceHandler.deviceCodeInfo) } diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d667e107..553eee7c 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -106,13 +106,13 @@ func (s *OidcService) ListClients(ctx context.Context, name string, listRequestO } // As allowedUserGroupsCount is not a column, we need to manually sort it - if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) { + if listRequestOptions.SortColumn == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) { query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)"). Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). Group("oidc_clients.id"). - Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction) + Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.SortDirection) - response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients) + response, err := utils.Paginate(listRequestOptions.Page, listRequestOptions.Limit, query, &clients) return clients, response, err } @@ -612,10 +612,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri // Handle custom sorting for lastUsedAt column var response utils.PaginationResponse - if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) { + if listRequestOptions.SortColumn == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.SortDirection) { query = query. Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID). - Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST") + Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.SortDirection + " NULLS LAST") } response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 0e37a5ac..1070342e 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -35,11 +35,11 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt } // As userCount is not a column we need to manually sort it - if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) { + if listRequestOptions.SortColumn == "userCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) { query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)"). Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id"). Group("user_groups.id"). - Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction) + Order("COUNT(user_groups_users.user_id) " + listRequestOptions.SortDirection) } response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups) diff --git a/backend/internal/usersignup/dto.go b/backend/internal/usersignup/dto.go index b7a4aad4..d452c883 100644 --- a/backend/internal/usersignup/dto.go +++ b/backend/internal/usersignup/dto.go @@ -1,23 +1,31 @@ package usersignup import ( + "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/dto" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" ) type signUpDto struct { - Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - Token string `json:"token"` + Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"` + Email *string `json:"email" required:"false" format:"email" unorm:"nfc"` + FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"` + LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"` + Token string `json:"token" required:"false"` +} + +func (d *signupTokenCreateDto) Resolve(huma.Context) []error { + if dto.ValidateTTL(d.TTL) { + return nil + } + return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}} } type signupTokenCreateDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` - UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` - UserGroupIDs []string `json:"userGroupIds"` + TTL utils.JSONDuration `json:"ttl" required:"true"` + UsageLimit int `json:"usageLimit" required:"true" minimum:"1" maximum:"100"` + UserGroupIDs []string `json:"userGroupIds" required:"false"` } type signupTokenDto struct { diff --git a/backend/internal/usersignup/handler.go b/backend/internal/usersignup/handler.go index 5abdbcd7..dbc21ac8 100644 --- a/backend/internal/usersignup/handler.go +++ b/backend/internal/usersignup/handler.go @@ -1,19 +1,36 @@ package usersignup import ( + "context" "net/http" "time" - "github.com/gin-gonic/gin" - "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/utils" + "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) const defaultSignupTokenDuration = time.Hour +type userOutput struct { + SetCookie []http.Cookie `header:"Set-Cookie"` + Body dto.UserDto +} + +type signupInput struct { + Body signUpDto +} + +type tokenCreateInput struct { + Body signupTokenCreateDto +} + +type tokenIDInput struct { + ID string `path:"id"` +} + type handler struct { service *Service appConfig AppConfigProvider @@ -23,175 +40,73 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler { return &handler{service: service, appConfig: appConfig} } -func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) { - setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context()) +func (h *handler) checkInitialAdminSetupAvailable(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { + setupCompleted, err := h.service.IsInitialAdminSetupCompleted(ctx) if err != nil { - _ = c.Error(err) - return + return nil, err } - if setupCompleted { - _ = c.Error(&common.SetupNotAvailableError{}) - return + return nil, &common.SetupNotAvailableError{} } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// signUpInitialAdmin godoc -// @Summary Sign up initial admin user -// @Description Sign up and generate setup access token for initial admin user -// @Tags Users -// @Accept json -// @Produce json -// @Param body body signUpDto true "User information" -// @Success 200 {object} dto.UserDto -// @Router /api/signup/setup [post] -func (h *handler) signUpInitialAdmin(c *gin.Context) { - var input signUpDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input) +func (h *handler) signUpInitialAdmin(ctx context.Context, input *signupInput) (*userOutput, error) { + user, token, err := h.service.SignUpInitialAdmin(ctx, input.Body) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, token) - - c.JSON(http.StatusOK, userDto) + return h.userOutput(user, token) } -// createSignupTokenHandler godoc -// @Summary Create signup token -// @Description Create a new signup token that allows user registration -// @Tags Users -// @Accept json -// @Produce json -// @Param token body signupTokenCreateDto true "Signup token information" -// @Success 201 {object} signupTokenDto -// @Router /api/signup-tokens [post] -func (h *handler) createSignupToken(c *gin.Context) { - var input signupTokenCreateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - ttl := input.TTL.Duration +func (h *handler) createSignupToken(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[signupTokenDto], error) { + ttl := input.Body.TTL.Duration if ttl <= 0 { ttl = defaultSignupTokenDuration } - - signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) + token, err := h.service.CreateSignupToken(ctx, ttl, input.Body.UsageLimit, input.Body.UserGroupIDs) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var tokenDto signupTokenDto - err = dto.MapStruct(signupToken, &tokenDto) - if err != nil { - _ = c.Error(err) - return + var output signupTokenDto + if err := dto.MapStruct(token, &output); err != nil { + return nil, err } - - c.JSON(http.StatusCreated, tokenDto) + return &httpapi.BodyOutput[signupTokenDto]{Body: output}, nil } -// listSignupTokensHandler godoc -// @Summary List signup tokens -// @Description Get a paginated list of signup tokens -// @Tags Users -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions) +func (h *handler) listSignupTokens(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[signupTokenDto]], error) { + tokens, pagination, err := h.service.ListSignupTokens(ctx, input.ListRequestOptions) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var tokensDto []signupTokenDto - if err := dto.MapStructList(tokens, &tokensDto); err != nil { - _ = c.Error(err) - return + var output []signupTokenDto + if err := dto.MapStructList(tokens, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{ - Data: tokensDto, - Pagination: pagination, - }) + return &httpapi.BodyOutput[dto.Paginated[signupTokenDto]]{Body: dto.Paginated[signupTokenDto]{Data: output, Pagination: pagination}}, nil } -// deleteSignupTokenHandler godoc -// @Summary Delete signup token -// @Description Delete a signup token by ID -// @Tags Users -// @Param id path string true "Token ID" -// @Success 204 "No Content" -// @Router /api/signup-tokens/{id} [delete] -func (h *handler) deleteSignupToken(c *gin.Context) { - tokenID := c.Param("id") - - err := h.service.DeleteSignupToken(c.Request.Context(), tokenID) - if err != nil { - _ = c.Error(err) - return +func (h *handler) deleteSignupToken(ctx context.Context, input *tokenIDInput) (*httpapi.EmptyOutput, error) { + if err := h.service.DeleteSignupToken(ctx, input.ID); err != nil { + return nil, err } - - c.Status(http.StatusNoContent) + return &httpapi.EmptyOutput{}, nil } -// signupHandler godoc -// @Summary Sign up -// @Description Create a new user account -// @Tags Users -// @Accept json -// @Produce json -// @Param user body signUpDto true "User information" -// @Success 201 {object} dto.UserDto -// @Router /api/signup [post] -func (h *handler) signup(c *gin.Context) { - var input signUpDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - ipAddress := c.ClientIP() - userAgent := c.GetHeader("User-Agent") - - user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent) +func (h *handler) signup(ctx context.Context, input *signupInput) (*userOutput, error) { + user, accessToken, err := h.service.SignUp(ctx, input.Body, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } + return h.userOutput(user, accessToken) +} +func (h *handler) userOutput(user model.User, accessToken string) (*userOutput, error) { + var output dto.UserDto + if err := dto.MapStruct(user, &output); err != nil { + return nil, err + } maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, accessToken) - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusCreated, userDto) + return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, accessToken)}, Body: output}, nil } diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index 54f4b0b8..6160e6ec 100644 --- a/backend/internal/usersignup/module.go +++ b/backend/internal/usersignup/module.go @@ -2,12 +2,14 @@ package usersignup import ( "context" + "net/http" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) type TokenService interface { @@ -50,11 +52,37 @@ func New(deps Dependencies) *Module { // 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) +func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation), signupRateLimit func(huma.Context, func(huma.Context))) { + createTokenOperation := signupOperation("create-signup-token", http.MethodPost, "/api/signup-tokens", "Create signup token") + createTokenOperation.DefaultStatus = http.StatusCreated + adminAuth(&createTokenOperation) + httpapi.Register(api, createTokenOperation, m.handler.createSignupToken) + + listTokensOperation := signupOperation("list-signup-tokens", http.MethodGet, "/api/signup-tokens", "List signup tokens") + adminAuth(&listTokensOperation) + httpapi.Register(api, listTokensOperation, m.handler.listSignupTokens) + + deleteTokenOperation := signupOperation("delete-signup-token", http.MethodDelete, "/api/signup-tokens/{id}", "Delete signup token") + deleteTokenOperation.DefaultStatus = http.StatusNoContent + adminAuth(&deleteTokenOperation) + httpapi.Register(api, deleteTokenOperation, m.handler.deleteSignupToken) + + selfSignupOperation := signupOperation("signup", http.MethodPost, "/api/signup", "Sign up") + selfSignupOperation.DefaultStatus = http.StatusCreated + selfSignupOperation.Middlewares = append(selfSignupOperation.Middlewares, signupRateLimit) + httpapi.Register(api, selfSignupOperation, m.handler.signup) + + setupAvailableOperation := signupOperation("check-initial-admin-setup", http.MethodGet, "/api/signup/setup", "Check initial admin setup availability") + setupAvailableOperation.DefaultStatus = http.StatusNoContent + httpapi.Register(api, setupAvailableOperation, m.handler.checkInitialAdminSetupAvailable) + + httpapi.Register(api, signupOperationForInitialAdmin(), m.handler.signUpInitialAdmin) +} + +func signupOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Users"}} +} + +func signupOperationForInitialAdmin() huma.Operation { + return signupOperation("signup-initial-admin", http.MethodPost, "/api/signup/setup", "Sign up initial admin user") } diff --git a/backend/internal/utils/cookie/add_cookie.go b/backend/internal/utils/cookie/add_cookie.go index c2b8ab74..76623f38 100644 --- a/backend/internal/utils/cookie/add_cookie.go +++ b/backend/internal/utils/cookie/add_cookie.go @@ -1,23 +1,35 @@ package cookie import ( + "net/http" "time" - - "github.com/gin-gonic/gin" ) -func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) { - c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true) +func NewAccessTokenCookie(maxAgeInSeconds int, token string) *http.Cookie { + return newCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/") } -func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) { - c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true) +func NewSessionIDCookie(maxAgeInSeconds int, sessionID string) *http.Cookie { + return newCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/") } -func AddDeviceTokenCookie(c *gin.Context, deviceToken string) { - c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true) +func NewDeviceTokenCookie(deviceToken string) *http.Cookie { + return newCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token") } -func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) { - c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true) +func NewReauthenticationTokenCookie(reauthenticationToken string) *http.Cookie { + return newCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/") +} + +func newCookie(name, value string, maxAge int, path string) *http.Cookie { + // SameSite remains unset to preserve the cookies emitted by the existing Gin helpers + //nolint:gosec + return &http.Cookie{ + Name: name, + Value: value, + Path: path, + MaxAge: maxAge, + Secure: true, + HttpOnly: true, + } } diff --git a/backend/internal/utils/csp.go b/backend/internal/utils/csp.go index ce42f41d..730f593c 100644 --- a/backend/internal/utils/csp.go +++ b/backend/internal/utils/csp.go @@ -35,6 +35,26 @@ func BuildFormPostCSP(nonce, redirectURI, scriptHash string) string { return buildCSP(nonce, []string{redirectURI}, []string{scriptHash}) } +// BuildAPIDocsCSP allows the pinned Scalar bundle and the assets it creates +func BuildAPIDocsCSP(nonce string) string { + scriptSrc := "script-src 'self' https://cdn.jsdelivr.net" + if nonce != "" { + scriptSrc += " 'nonce-" + nonce + "'" + } + + return "default-src 'self'; " + + "base-uri 'self'; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "form-action 'self'; " + + "img-src * blob: data:; " + + "font-src 'self' https://cdn.jsdelivr.net data:; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + + "worker-src blob:; " + + "connect-src 'self'; " + + scriptSrc +} + func buildCSP(nonce string, formActionExtra, scriptSrcExtra []string) string { formAction := "'self'" scriptSrc := "script-src 'self'" diff --git a/backend/internal/utils/http_util.go b/backend/internal/utils/http_util.go index 06811c4b..ae087392 100644 --- a/backend/internal/utils/http_util.go +++ b/backend/internal/utils/http_util.go @@ -3,17 +3,10 @@ package utils import ( "strconv" "time" - - "github.com/gin-gonic/gin" ) -// SetCacheControlHeader sets the Cache-Control header for the response. -func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) { - _, ok := ctx.GetQuery("skipCache") - if !ok { - maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds())) - staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds())) - ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds) - } - +func CacheControlValue(maxAge, staleWhileRevalidate time.Duration) string { + maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds())) + staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds())) + return "public, max-age=" + maxAgeSeconds + ", stale-while-revalidate=" + staleWhileRevalidateSeconds } diff --git a/backend/internal/utils/huma/api.go b/backend/internal/utils/huma/api.go new file mode 100644 index 00000000..a1b4295a --- /dev/null +++ b/backend/internal/utils/huma/api.go @@ -0,0 +1,118 @@ +package humautils + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" +) + +const scalarDocsHTML = ` + + + Pocket ID API Reference + + + + + + + +` + +var ginCompatibleJSONFormat = huma.Format{ + Marshal: func(w io.Writer, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = w.Write(data) + return err + }, + Unmarshal: json.Unmarshal, +} + +// New creates the Huma API on the existing rate-limited Gin group +func New(r *gin.Engine, group *gin.RouterGroup) huma.API { + config := huma.DefaultConfig("Pocket ID API", common.Version) + config.CreateHooks = nil + config.DocsPath = "" + config.OpenAPIPath = "/api/openapi" + config.SchemasPath = "/api/schemas" + config.AllowAdditionalPropertiesByDefault = true + config.Security = nil + config.Formats = map[string]huma.Format{ + "application/json": ginCompatibleJSONFormat, + "json": ginCompatibleJSONFormat, + } + config.DefaultFormat = "application/json" + config.OnAddOperation = append(config.OnAddOperation, rewriteValidationResponse) + if common.EnvConfig.AppURL != "" { + config.Servers = []*huma.Server{{URL: common.EnvConfig.AppURL}} + } + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "BearerAuth": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "Pocket ID session JWT sent in the Authorization header", + }, + "SessionCookie": { + Type: "apiKey", + In: "cookie", + Name: cookie.AccessTokenCookieName, + Description: "Pocket ID browser session cookie", + }, + "ApiKeyAuth": { + Type: "apiKey", + In: "header", + Name: "X-API-Key", + Description: "Pocket ID API key", + }, + "OIDCAccessToken": { + Type: "http", + Scheme: "bearer", + Description: "OIDC access token", + }, + "OIDCClientBasic": { + Type: "http", + Scheme: "basic", + Description: "OIDC client credentials", + }, + } + + humagin.MultipartMaxMemory = r.MaxMultipartMemory + api := humagin.NewWithGroup(r, group, config) + api.UseMiddleware(CaptureRequestContext) + registerScalarDocs(group) + return api +} + +func rewriteValidationResponse(_ *huma.OpenAPI, operation *huma.Operation) { + response, ok := operation.Responses["422"] + if !ok { + return + } + if _, exists := operation.Responses["400"]; !exists { + operation.Responses["400"] = response + } + delete(operation.Responses, "422") +} + +func registerScalarDocs(group *gin.RouterGroup) { + group.GET("/api/docs", func(ctx *gin.Context) { + nonce := utils.GetCSPNonce(ctx) + ctx.Header("Content-Security-Policy", utils.BuildAPIDocsCSP(nonce)) + ctx.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarDocsHTML)) + }) +} diff --git a/backend/internal/utils/huma/api_test.go b/backend/internal/utils/huma/api_test.go new file mode 100644 index 00000000..3f195ee5 --- /dev/null +++ b/backend/internal/utils/huma/api_test.go @@ -0,0 +1,162 @@ +package humautils + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type testInput struct { + Body struct { + Name string `json:"name" required:"true" minLength:"3"` + } +} + +type testOutput struct { + Body map[string]string +} + +type testCookieOutput struct { + SetCookie []http.Cookie `header:"Set-Cookie"` +} + +type testStreamOutput struct { + ContentType string `header:"Content-Type"` + Body func(huma.Context) +} + +type optionalBodyInput struct { + Body *json.RawMessage `required:"false"` +} + +type testAppError struct{} + +func (testAppError) Error() string { return "test error" } +func (testAppError) Description() string { return "test description" } +func (testAppError) HttpStatusCode() int { return http.StatusConflict } + +type trackingReader struct { + io.Reader + closed bool +} + +func (r *trackingReader) Close() error { + r.closed = true + return nil +} + +func newTestAPI(t *testing.T) (*gin.Engine, huma.API) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + api := New(router, router.Group("/")) + return router, api +} + +func TestRequestAndErrorCompatibility(t *testing.T) { + router, api := newTestAPI(t) + Register(api, huma.Operation{OperationID: "test-request", Method: http.MethodPost, Path: "/api/test"}, func(_ context.Context, input *testInput) (*testOutput, error) { + return &testOutput{Body: map[string]string{"name": input.Body.Name}}, nil + }) + Register(api, huma.Operation{OperationID: "test-app-error", Method: http.MethodGet, Path: "/api/test-error"}, func(context.Context, *struct{}) (*struct{}, error) { + return nil, testAppError{} + }) + Register(api, huma.Operation{OperationID: "test-unknown-error", Method: http.MethodGet, Path: "/api/test-unknown-error"}, func(context.Context, *struct{}) (*struct{}, error) { + return nil, errors.New("private failure") + }) + Register(api, huma.Operation{OperationID: "test-optional-body", Method: http.MethodPost, Path: "/api/test-optional-body", DefaultStatus: http.StatusNoContent}, func(_ context.Context, input *optionalBodyInput) (*struct{}, error) { + require.Nil(t, input.Body) + return &struct{}{}, nil + }) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"Pocket ID","unknown":true}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, "application/json", response.Header().Get("Content-Type")) + require.JSONEq(t, `{"name":"Pocket ID"}`, response.Body.String()) + require.Empty(t, response.Header().Get("Link")) + require.NotContains(t, response.Body.String(), "$schema") + + request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", nil) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type")) + require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String()) + + request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"x"}`)) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusBadRequest, response.Code) + require.JSONEq(t, `{"error":"Expected length >= 3"}`, response.Body.String()) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-error", nil)) + require.Equal(t, http.StatusConflict, response.Code) + require.JSONEq(t, `{"error":"Test error","error_description":"test description"}`, response.Body.String()) + require.Less(t, strings.Index(response.Body.String(), `"error"`), strings.Index(response.Body.String(), `"error_description"`)) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-unknown-error", nil)) + require.Equal(t, http.StatusInternalServerError, response.Code) + require.JSONEq(t, `{"error":"Something went wrong"}`, response.Body.String()) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-optional-body", nil)) + require.Equal(t, http.StatusNoContent, response.Code) +} + +func TestCookiesStreamingAndOpenAPI(t *testing.T) { + router, api := newTestAPI(t) + Register(api, huma.Operation{OperationID: "test-cookies", Method: http.MethodPost, Path: "/api/test-cookies", DefaultStatus: http.StatusNoContent}, func(context.Context, *struct{}) (*testCookieOutput, error) { + return &testCookieOutput{SetCookie: []http.Cookie{{Name: "one", Value: "1"}, {Name: "two", Value: "2"}}}, nil + }) + + reader := &trackingReader{Reader: strings.NewReader("streamed")} + Register(api, huma.Operation{OperationID: "test-stream", Method: http.MethodGet, Path: "/api/test-stream"}, func(context.Context, *struct{}) (*testStreamOutput, error) { + return &testStreamOutput{ContentType: "text/plain", Body: func(ctx huma.Context) { + defer reader.Close() + _, _ = io.Copy(ctx.BodyWriter(), reader) + }}, nil + }) + AddRawOperation(api, "test-raw", http.MethodPost, "/api/test-raw", "Raw test", []string{"Test"}, nil) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-cookies", nil)) + require.Equal(t, http.StatusNoContent, response.Code) + require.Equal(t, []string{"one=1", "two=2"}, response.Header().Values("Set-Cookie")) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-stream", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, "text/plain", response.Header().Get("Content-Type")) + require.Equal(t, "streamed", response.Body.String()) + require.True(t, reader.closed) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Body.String(), `"/api/test-raw"`) + require.NotContains(t, response.Body.String(), `"422"`) + require.NotContains(t, response.Body.String(), `"$schema"`) + + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5") + require.Contains(t, response.Header().Get("Content-Security-Policy"), "worker-src blob:") + require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'") +} diff --git a/backend/internal/utils/huma/context.go b/backend/internal/utils/huma/context.go new file mode 100644 index 00000000..3f732ee0 --- /dev/null +++ b/backend/internal/utils/huma/context.go @@ -0,0 +1,101 @@ +package humautils + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" +) + +type contextKey uint8 + +const ( + requestContextKey contextKey = iota + clientIPContextKey + userIDContextKey + userIsAdminContextKey + authenticationMethodContextKey + authenticationTimeContextKey +) + +// CaptureRequestContext exposes trusted Gin request metadata to typed handlers +func CaptureRequestContext(ctx huma.Context, next func(huma.Context)) { + ginCtx := humagin.Unwrap(ctx) + ctx = huma.WithValue(ctx, requestContextKey, ginCtx.Request) + ctx = huma.WithValue(ctx, clientIPContextKey, ginCtx.ClientIP()) + next(ctx) +} + +// WithAuthentication adds the authenticated identity to a Huma request context +func WithAuthentication(ctx huma.Context, userID string, isAdmin bool, method string, authenticationTime time.Time) huma.Context { + ctx = huma.WithValue(ctx, userIDContextKey, userID) + ctx = huma.WithValue(ctx, userIsAdminContextKey, isAdmin) + ctx = huma.WithValue(ctx, authenticationMethodContextKey, method) + return huma.WithValue(ctx, authenticationTimeContextKey, authenticationTime) +} + +// Request returns the underlying HTTP request for protocol handlers that require it +func Request(ctx context.Context) *http.Request { + request, _ := ctx.Value(requestContextKey).(*http.Request) + return request +} + +// ClientIP returns the trusted client IP calculated by Gin +func ClientIP(ctx context.Context) string { + value, _ := ctx.Value(clientIPContextKey).(string) + return value +} + +// UserAgent returns the request user agent +func UserAgent(ctx context.Context) string { + request := Request(ctx) + if request == nil { + return "" + } + return request.UserAgent() +} + +// Cookie returns a dynamically named request cookie +func Cookie(ctx context.Context, name string) (*http.Cookie, error) { + request := Request(ctx) + if request == nil { + return nil, http.ErrNoCookie + } + return request.Cookie(name) +} + +// QueryPresent reports whether a query key was present regardless of its value +func QueryPresent(ctx context.Context, name string) bool { + request := Request(ctx) + if request == nil { + return false + } + _, ok := request.URL.Query()[name] + return ok +} + +// UserID returns the authenticated user ID +func UserID(ctx context.Context) string { + value, _ := ctx.Value(userIDContextKey).(string) + return value +} + +// IsAdmin reports whether the authenticated user is an administrator +func IsAdmin(ctx context.Context) bool { + value, _ := ctx.Value(userIsAdminContextKey).(bool) + return value +} + +// AuthenticationMethod returns the session authentication method +func AuthenticationMethod(ctx context.Context) string { + value, _ := ctx.Value(authenticationMethodContextKey).(string) + return value +} + +// AuthenticationTime returns the session authentication time +func AuthenticationTime(ctx context.Context) time.Time { + value, _ := ctx.Value(authenticationTimeContextKey).(time.Time) + return value +} diff --git a/backend/internal/utils/huma/errors.go b/backend/internal/utils/huma/errors.go new file mode 100644 index 00000000..fa9d76b1 --- /dev/null +++ b/backend/internal/utils/huma/errors.go @@ -0,0 +1,98 @@ +package humautils + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strings" + "unicode" + "unicode/utf8" + + "github.com/danielgtaylor/huma/v2" + "github.com/pocket-id/pocket-id/backend/internal/common" + "gorm.io/gorm" +) + +type apiError struct { + status int + Message string `json:"error"` + Description string `json:"error_description,omitempty"` +} + +func init() { + huma.NewError = newHumaError +} + +func (e *apiError) Error() string { return e.Message } +func (e *apiError) GetStatus() int { return e.status } + +func (e *apiError) ContentType(contentType string) string { + if contentType == "application/json" { + return "application/json; charset=utf-8" + } + return contentType +} + +func newHumaError(status int, message string, errs ...error) huma.StatusError { + if status == http.StatusUnprocessableEntity { + status = http.StatusBadRequest + } + + messages := make([]string, 0, len(errs)) + for _, err := range errs { + if err == nil { + continue + } + var detailer huma.ErrorDetailer + if errors.As(err, &detailer) { + messages = append(messages, detailer.ErrorDetail().Message) + continue + } + messages = append(messages, err.Error()) + } + if len(messages) > 0 { + message = strings.Join(messages, ", ") + } + + return &apiError{status: status, Message: capitalize(message)} +} + +func capitalize(message string) string { + if message == "" { + return message + } + r, size := utf8.DecodeRuneInString(message) + return string(unicode.ToUpper(r)) + message[size:] +} + +func mapError(ctx context.Context, err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return &apiError{status: http.StatusNotFound, Message: "Record not found"} + } + + var appDescriptionError common.AppErrorDescription + if errors.As(err, &appDescriptionError) { + return &apiError{ + status: appDescriptionError.HttpStatusCode(), + Message: capitalize(appDescriptionError.Error()), + Description: appDescriptionError.Description(), + } + } + + var appError common.AppError + if errors.As(err, &appError) { + return &apiError{status: appError.HttpStatusCode(), Message: capitalize(appError.Error())} + } + + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + return &apiError{status: http.StatusRequestEntityTooLarge, Message: "The request body is too large"} + } + + slog.ErrorContext(ctx, "Unhandled API error", slog.Any("error", err)) + return &apiError{status: http.StatusInternalServerError, Message: "Something went wrong"} +} diff --git a/backend/internal/utils/huma/raw.go b/backend/internal/utils/huma/raw.go new file mode 100644 index 00000000..54088648 --- /dev/null +++ b/backend/internal/utils/huma/raw.go @@ -0,0 +1,28 @@ +package humautils + +import ( + "net/http" + "strconv" + + "github.com/danielgtaylor/huma/v2" +) + +// AddRawOperation documents a Gin endpoint that must retain direct response control +func AddRawOperation(api huma.API, operationID, method, path, summary string, tags []string, security []map[string][]string, statuses ...int) { + if len(statuses) == 0 { + statuses = []int{http.StatusOK} + } + responses := make(map[string]*huma.Response, len(statuses)) + for _, status := range statuses { + responses[strconv.Itoa(status)] = &huma.Response{Description: http.StatusText(status)} + } + api.OpenAPI().AddOperation(&huma.Operation{ + OperationID: operationID, + Method: method, + Path: path, + Summary: summary, + Tags: tags, + Security: security, + Responses: responses, + }) +} diff --git a/backend/internal/utils/huma/register.go b/backend/internal/utils/huma/register.go new file mode 100644 index 00000000..96c5e0ed --- /dev/null +++ b/backend/internal/utils/huma/register.go @@ -0,0 +1,24 @@ +package humautils + +import ( + "context" + + "github.com/danielgtaylor/huma/v2" +) + +// Register adds a typed operation while preserving Pocket ID error and body-reading behavior +func Register[I, O any](api huma.API, operation huma.Operation, handler func(context.Context, *I) (*O, error)) { + if operation.MaxBodyBytes == 0 { + operation.MaxBodyBytes = -1 + } + if operation.BodyReadTimeout == 0 { + operation.BodyReadTimeout = -1 + } + huma.Register(api, operation, func(ctx context.Context, input *I) (*O, error) { + output, err := handler(ctx, input) + if err != nil { + return nil, mapError(ctx, err) + } + return output, nil + }) +} diff --git a/backend/internal/utils/huma/types.go b/backend/internal/utils/huma/types.go new file mode 100644 index 00000000..cbc5af71 --- /dev/null +++ b/backend/internal/utils/huma/types.go @@ -0,0 +1,19 @@ +package humautils + +import "github.com/pocket-id/pocket-id/backend/internal/utils" + +// EmptyInput represents an operation without path, query, header, or body input +type EmptyInput struct{} + +// EmptyOutput represents an operation without a response body or headers +type EmptyOutput struct{} + +// BodyOutput wraps a typed response body for Huma +type BodyOutput[T any] struct { + Body T +} + +// ListInput exposes the shared list query parameters to Huma +type ListInput struct { + utils.ListRequestOptions +} diff --git a/backend/internal/utils/json_util.go b/backend/internal/utils/json_util.go index 476fdd9a..b8154ebc 100644 --- a/backend/internal/utils/json_util.go +++ b/backend/internal/utils/json_util.go @@ -5,13 +5,20 @@ import ( "errors" "fmt" "time" + + "github.com/danielgtaylor/huma/v2" ) // JSONDuration is a type that allows marshalling/unmarshalling a Duration -type JSONDuration struct { +type JSONDuration struct { //nolint:recvcheck time.Duration } +// Schema documents the string and numeric representations accepted by UnmarshalJSON +func (d JSONDuration) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{OneOf: []*huma.Schema{{Type: huma.TypeString}, {Type: huma.TypeNumber}}} +} + func (d JSONDuration) MarshalJSON() ([]byte, error) { return json.Marshal(d.String()) } diff --git a/backend/internal/utils/list_request_util.go b/backend/internal/utils/list_request_util.go index b0bb34c8..0b3c7669 100644 --- a/backend/internal/utils/list_request_util.go +++ b/backend/internal/utils/list_request_util.go @@ -1,10 +1,11 @@ package utils import ( + "net/url" "reflect" "strings" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -17,15 +18,11 @@ type PaginationResponse struct { } type ListRequestOptions struct { - Pagination struct { - Page int `form:"pagination[page]"` - Limit int `form:"pagination[limit]"` - } `form:"pagination"` - Sort struct { - Column string `form:"sort[column]"` - Direction string `form:"sort[direction]"` - } `form:"sort"` - Filters map[string][]any + Page int `query:"pagination[page]" required:"false"` + Limit int `query:"pagination[limit]" required:"false"` + SortColumn string `query:"sort[column]" required:"false"` + SortDirection string `query:"sort[direction]" required:"false"` + Filters map[string][]any } type FieldMeta struct { @@ -34,22 +31,19 @@ type FieldMeta struct { IsFilterable bool } -func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) { - if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil { - return listRequestOptions - } - - listRequestOptions.Filters = parseNestedFilters(ctx) - return listRequestOptions +func (options *ListRequestOptions) Resolve(ctx huma.Context) []error { + requestURL := ctx.URL() + options.Filters = parseNestedFilters(requestURL.Query()) + return nil } func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) { meta := extractModelMetadata(result) query = applyFilters(params.Filters, query, meta) - query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta) + query = applySorting(params.SortColumn, params.SortDirection, query, meta) - return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result) + return Paginate(params.Page, params.Limit, query, result) } func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) { @@ -105,9 +99,8 @@ func IsValidSortDirection(direction string) bool { } // parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2 -func parseNestedFilters(ctx *gin.Context) map[string][]any { +func parseNestedFilters(query url.Values) map[string][]any { result := make(map[string][]any) - query := ctx.Request.URL.Query() for key, values := range query { if !strings.HasPrefix(key, "filters[") { diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go index 1cfeb447..4a8b24c1 100644 --- a/backend/internal/webauthn/handler.go +++ b/backend/internal/webauthn/handler.go @@ -1,16 +1,46 @@ package webauthn import ( + "bytes" + "context" + "encoding/json" + "io" "net/http" - "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/dto" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) +type emptyOutput struct { + SetCookie []http.Cookie `header:"Set-Cookie"` +} + +type bodyOutput[T any] struct { + SetCookie []http.Cookie `header:"Set-Cookie"` + Body T +} + +type credentialBodyInput struct { + Body json.RawMessage +} + +type optionalCredentialBodyInput struct { + Body *json.RawMessage `required:"false"` +} + +type credentialIDInput struct { + ID string `path:"id"` +} + +type credentialUpdateInput struct { + ID string `path:"id"` + Body dto.WebauthnCredentialUpdateDto +} + type handler struct { service *Service appConfig AppConfigProvider @@ -20,172 +50,148 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler { return &handler{service: service, appConfig: appConfig} } -func (h *handler) beginRegistration(c *gin.Context) { - userID := c.GetString("userID") - options, err := h.service.BeginRegistration(c.Request.Context(), userID) +func (h *handler) beginRegistration(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialCreationOptions], error) { + options, err := h.service.BeginRegistration(ctx, httpapi.UserID(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) - c.JSON(http.StatusOK, options.Response) + return &bodyOutput[protocol.PublicKeyCredentialCreationOptions]{ + SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)}, + Body: options.Response, + }, nil } -func (h *handler) verifyRegistration(c *gin.Context) { - sessionID, err := c.Cookie(cookie.SessionIdCookieName) +func (h *handler) verifyRegistration(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.WebauthnCredentialDto], error) { + sessionID, err := sessionID(ctx) if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return nil, err } - - userID := c.GetString("userID") - credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) + request := requestWithBody(ctx, input.Body) + credential, err := h.service.VerifyRegistration(ctx, sessionID, httpapi.UserID(ctx), request, httpapi.ClientIP(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var credentialDto dto.WebauthnCredentialDto - if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return + var output dto.WebauthnCredentialDto + if err := dto.MapStruct(credential, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, credentialDto) + return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil } -func (h *handler) beginLogin(c *gin.Context) { - options, err := h.service.BeginLogin(c.Request.Context()) +func (h *handler) beginLogin(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialRequestOptions], error) { + options, err := h.service.BeginLogin(ctx) if err != nil { - _ = c.Error(err) - return + return nil, err } - - cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) - c.JSON(http.StatusOK, options.Response) + return &bodyOutput[protocol.PublicKeyCredentialRequestOptions]{ + SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)}, + Body: options.Response, + }, nil } -func (h *handler) verifyLogin(c *gin.Context) { - sessionID, err := c.Cookie(cookie.SessionIdCookieName) +func (h *handler) verifyLogin(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.UserDto], error) { + sessionID, err := sessionID(ctx) if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return nil, err } - - credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) + assertion, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(input.Body)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - user, token, err := h.service.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) + user, token, err := h.service.VerifyLogin(ctx, sessionID, assertion, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return + var output dto.UserDto + if err := dto.MapStruct(user, &output); err != nil { + return nil, err } - maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, token) - - c.JSON(http.StatusOK, userDto) + return &bodyOutput[dto.UserDto]{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil } -func (h *handler) listCredentials(c *gin.Context) { - userID := c.GetString("userID") - credentials, err := h.service.ListCredentials(c.Request.Context(), userID) +func (h *handler) listCredentials(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[[]dto.WebauthnCredentialDto], error) { + credentials, err := h.service.ListCredentials(ctx, httpapi.UserID(ctx)) if err != nil { - _ = c.Error(err) - return + return nil, err } - - var credentialDtos []dto.WebauthnCredentialDto - if err := dto.MapStructList(credentials, &credentialDtos); err != nil { - _ = c.Error(err) - return + var output []dto.WebauthnCredentialDto + if err := dto.MapStructList(credentials, &output); err != nil { + return nil, err } - - c.JSON(http.StatusOK, credentialDtos) + return &bodyOutput[[]dto.WebauthnCredentialDto]{Body: output}, nil } -func (h *handler) deleteCredential(c *gin.Context) { - userID := c.GetString("userID") - credentialID := c.Param("id") - clientIP := c.ClientIP() - userAgent := c.Request.UserAgent() +func (h *handler) deleteCredential(ctx context.Context, input *credentialIDInput) (*emptyOutput, error) { + userID := httpapi.UserID(ctx) + if err := h.service.DeleteCredential(ctx, userID, input.ID, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx), userID); err != nil { + return nil, err + } + return &emptyOutput{}, nil +} - err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID) +func (h *handler) updateCredential(ctx context.Context, input *credentialUpdateInput) (*bodyOutput[dto.WebauthnCredentialDto], error) { + credential, err := h.service.UpdateCredential(ctx, httpapi.UserID(ctx), input.ID, input.Body.Name) if err != nil { - _ = c.Error(err) - return + return nil, err } - - c.Status(http.StatusNoContent) + var output dto.WebauthnCredentialDto + if err := dto.MapStruct(credential, &output); err != nil { + return nil, err + } + return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil } -func (h *handler) updateCredential(c *gin.Context) { - userID := c.GetString("userID") - credentialID := c.Param("id") +func (h *handler) logout(_ context.Context, _ *httpapi.EmptyInput) (*emptyOutput, error) { + return &emptyOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(0, "")}}, nil +} - var input dto.WebauthnCredentialUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name) +func (h *handler) reauthenticate(ctx context.Context, input *optionalCredentialBodyInput) (*emptyOutput, error) { + sessionID, err := sessionID(ctx) if err != nil { - _ = c.Error(err) - return - } - - var credentialDto dto.WebauthnCredentialDto - if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, credentialDto) -} - -func (h *handler) logout(c *gin.Context) { - cookie.AddAccessTokenCookie(c, 0, "") - c.Status(http.StatusNoContent) -} - -func (h *handler) reauthenticate(c *gin.Context) { - sessionID, err := c.Cookie(cookie.SessionIdCookieName) - if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return + return nil, err } var token string - - // Try to create a reauthentication token with WebAuthn - credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) - if err == nil { - token, err = h.service.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData) - if err != nil { - _ = c.Error(err) - return + if input.Body != nil { + assertion, parseErr := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(*input.Body)) + if parseErr == nil { + token, err = h.service.CreateReauthenticationTokenWithWebauthn(ctx, sessionID, assertion) + } else { + token, err = h.reauthenticateWithAccessToken(ctx) } } 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 - } + token, err = h.reauthenticateWithAccessToken(ctx) } - - cookie.AddReauthenticationTokenCookie(c, token) - c.Status(http.StatusNoContent) + if err != nil { + return nil, err + } + return &emptyOutput{SetCookie: []http.Cookie{*cookie.NewReauthenticationTokenCookie(token)}}, nil +} + +func (h *handler) reauthenticateWithAccessToken(ctx context.Context) (string, error) { + accessToken, _ := httpapi.Cookie(ctx, cookie.AccessTokenCookieName) + value := "" + if accessToken != nil { + value = accessToken.Value + } + return h.service.CreateReauthenticationTokenWithAccessToken(ctx, value) +} + +func sessionID(ctx context.Context) (string, error) { + id, err := httpapi.Cookie(ctx, cookie.SessionIdCookieName) + if err != nil { + return "", &common.MissingSessionIdError{} + } + return id.Value, nil +} + +func requestWithBody(ctx context.Context, body []byte) *http.Request { + request := httpapi.Request(ctx).Clone(ctx) + request.Body = http.NoBody + if len(body) > 0 { + request.Body = io.NopCloser(bytes.NewReader(body)) + } + request.ContentLength = int64(len(body)) + return request } diff --git a/backend/internal/webauthn/handler_test.go b/backend/internal/webauthn/handler_test.go new file mode 100644 index 00000000..3447c9f9 --- /dev/null +++ b/backend/internal/webauthn/handler_test.go @@ -0,0 +1,49 @@ +package webauthn + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" +) + +func TestRequestWithBodyReconstructsUnderlyingRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + api := httpapi.New(router, router.Group("/")) + type input struct { + Body map[string]string + } + type output struct { + Body map[string]string + } + httpapi.Register(api, huma.Operation{OperationID: "reconstruct-request", Method: http.MethodPost, Path: "/api/reconstruct"}, func(ctx context.Context, _ *input) (*output, error) { + request := requestWithBody(ctx, []byte(`{"credential":"value"}`)) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.True(t, bytes.Equal([]byte(`{"credential":"value"}`), body)) + require.Equal(t, int64(len(body)), request.ContentLength) + return &output{Body: map[string]string{"status": "ok"}}, nil + }) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/reconstruct", http.NoBody) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusBadRequest, response.Code) + + request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/reconstruct", strings.NewReader(`{"input":"present"}`)) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusOK, response.Code) +} diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go index 41d945e8..a448b002 100644 --- a/backend/internal/webauthn/module.go +++ b/backend/internal/webauthn/module.go @@ -2,13 +2,15 @@ package webauthn import ( "context" + "net/http" "time" - "github.com/gin-gonic/gin" + "github.com/danielgtaylor/huma/v2" "github.com/lestrrat-go/jwx/v3/jwt" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/model" + httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" ) type TokenService interface { @@ -53,20 +55,48 @@ 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) +func (m *Module) RegisterRoutes(api huma.API, userAuth func(*huma.Operation), loginRateLimit, reauthRateLimit func(huma.Context, func(huma.Context))) { + beginRegistration := webauthnOperation("begin-webauthn-registration", http.MethodGet, "/api/webauthn/register/start", "Begin WebAuthn registration") + userAuth(&beginRegistration) + httpapi.Register(api, beginRegistration, m.handler.beginRegistration) - apiGroup.GET("/webauthn/login/start", m.handler.beginLogin) - apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin) + verifyRegistration := webauthnOperation("finish-webauthn-registration", http.MethodPost, "/api/webauthn/register/finish", "Finish WebAuthn registration") + userAuth(&verifyRegistration) + httpapi.Register(api, verifyRegistration, m.handler.verifyRegistration) - apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout) + httpapi.Register(api, webauthnOperation("begin-webauthn-login", http.MethodGet, "/api/webauthn/login/start", "Begin WebAuthn login"), m.handler.beginLogin) - apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate) + verifyLogin := webauthnOperation("finish-webauthn-login", http.MethodPost, "/api/webauthn/login/finish", "Finish WebAuthn login") + verifyLogin.Middlewares = append(verifyLogin.Middlewares, loginRateLimit) + httpapi.Register(api, verifyLogin, m.handler.verifyLogin) - 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) + logout := webauthnOperation("webauthn-logout", http.MethodPost, "/api/webauthn/logout", "Log out") + logout.DefaultStatus = http.StatusNoContent + userAuth(&logout) + httpapi.Register(api, logout, m.handler.logout) + + reauthenticate := webauthnOperation("webauthn-reauthenticate", http.MethodPost, "/api/webauthn/reauthenticate", "Reauthenticate") + reauthenticate.DefaultStatus = http.StatusNoContent + userAuth(&reauthenticate) + reauthenticate.Middlewares = append(reauthenticate.Middlewares, reauthRateLimit) + httpapi.Register(api, reauthenticate, m.handler.reauthenticate) + + listCredentials := webauthnOperation("list-webauthn-credentials", http.MethodGet, "/api/webauthn/credentials", "List WebAuthn credentials") + userAuth(&listCredentials) + httpapi.Register(api, listCredentials, m.handler.listCredentials) + + updateCredential := webauthnOperation("update-webauthn-credential", http.MethodPatch, "/api/webauthn/credentials/{id}", "Update WebAuthn credential") + userAuth(&updateCredential) + httpapi.Register(api, updateCredential, m.handler.updateCredential) + + deleteCredential := webauthnOperation("delete-webauthn-credential", http.MethodDelete, "/api/webauthn/credentials/{id}", "Delete WebAuthn credential") + deleteCredential.DefaultStatus = http.StatusNoContent + userAuth(&deleteCredential) + httpapi.Register(api, deleteCredential, m.handler.deleteCredential) +} + +func webauthnOperation(id, method, path, summary string) huma.Operation { + return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"WebAuthn"}} } // ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 92c7515e..ba4eafa7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -140,7 +140,7 @@ "name_passkey": "Name Passkey", "name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", "create_api_key": "Create API Key", - "add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the Pocket ID API.", + "add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the Pocket ID API.", "add_api_key": "Add API Key", "manage_api_keys": "Manage API Keys", "api_key_created": "API Key Created", diff --git a/frontend/package.json b/frontend/package.json index ef2598b7..9d066959 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,67 +1,67 @@ { - "name": "pocket-id-frontend", - "version": "2.10.0", - "private": true, - "type": "module", - "scripts": { - "preinstall": "npx only-allow pnpm", - "dev": "vite dev --port 3000", - "build": "vite build", - "preview": "vite preview --port 3000", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint .", - "format": "prettier --write ." - }, - "dependencies": { - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", - "@opentelemetry/resources": "^2.8.0", - "@opentelemetry/sdk-trace-web": "^2.8.0", - "@opentelemetry/semantic-conventions": "^1.41.1", - "@simplewebauthn/browser": "^13.3.0", - "@tailwindcss/vite": "^4.3.0", - "axios": "^1.16.1", - "clsx": "^2.1.1", - "date-fns": "^4.2.1", - "qrcode": "^1.5.4", - "runed": "^0.37.1", - "sveltekit-superforms": "^2.30.1", - "tailwind-merge": "^3.6.0", - "zod": "^4.4.3" - }, - "devDependencies": { - "@inlang/paraglide-js": "^2.18.0", - "@inlang/plugin-m-function-matcher": "^2.2.6", - "@inlang/plugin-message-format": "^4.4.0", - "@internationalized/date": "^3.12.1", - "@lucide/svelte": "^1.16.0", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.60.1", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^25.9.0", - "@types/qrcode": "^1.5.6", - "bits-ui": "^2.18.1", - "eslint": "^10.4.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.17.1", - "formsnap": "^2.0.1", - "globals": "^17.6.0", - "mode-watcher": "^1.1.0", - "prettier": "^3.8.3", - "prettier-plugin-svelte": "^3.5.2", - "prettier-plugin-tailwindcss": "^0.8.0", - "shadcn-svelte": "^1.3.0", - "svelte": "^5.55.8", - "svelte-check": "^4.4.8", - "svelte-sonner": "^1.1.1", - "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.3.0", - "tslib": "^2.8.1", - "tw-animate-css": "^1.4.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.59.4", - "vite": "^8.0.16", - "vite-plugin-compression": "^0.5.1" - } + "name": "pocket-id-frontend", + "version": "2.10.0", + "private": true, + "type": "module", + "scripts": { + "preinstall": "npx only-allow pnpm", + "dev": "vite dev --port 3000", + "build": "vite build", + "preview": "vite preview --port 3000", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-trace-web": "^2.8.0", + "@opentelemetry/semantic-conventions": "^1.41.1", + "@simplewebauthn/browser": "^13.3.0", + "@tailwindcss/vite": "^4.3.0", + "axios": "^1.16.1", + "clsx": "^2.1.1", + "date-fns": "^4.2.1", + "qrcode": "^1.5.4", + "runed": "^0.37.1", + "sveltekit-superforms": "^2.30.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@inlang/paraglide-js": "^2.18.0", + "@inlang/plugin-m-function-matcher": "^2.2.6", + "@inlang/plugin-message-format": "^4.4.0", + "@internationalized/date": "^3.12.1", + "@lucide/svelte": "^1.16.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.60.1", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^25.9.0", + "@types/qrcode": "^1.5.6", + "bits-ui": "^2.18.1", + "eslint": "^10.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.1", + "formsnap": "^2.0.1", + "globals": "^17.6.0", + "mode-watcher": "^1.1.0", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^3.5.2", + "prettier-plugin-tailwindcss": "^0.8.0", + "shadcn-svelte": "^1.3.0", + "svelte": "^5.55.8", + "svelte-check": "^4.4.8", + "svelte-sonner": "^1.1.1", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.0", + "tslib": "^2.8.1", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vite": "^8.0.16", + "vite-plugin-compression": "^0.5.1" + } }