refactor: standardize API error handling (#1635)

This commit is contained in:
Elias Schneider
2026-08-02 23:36:06 +02:00
committed by GitHub
parent 281bea54d3
commit 7a4d0dd275
108 changed files with 3163 additions and 1629 deletions

View File

@@ -64,4 +64,4 @@ formatters:
paths:
- third_party$
- builtin$
- examples$
- examples$

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -29,22 +30,20 @@ func newHandler(service *Service) *handler {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiResponseDto]
// @Router /api/apis [get]
func (h *handler) list(c *gin.Context) {
func (h *handler) list(c *gin.Context) error {
search := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
items := make([]apiResponseDto, len(apis))
for i, api := range apis {
var item apiResponseDto
if err := dto.MapStruct(api, &item); err != nil {
_ = c.Error(err)
return
return err
}
item.Resource = api.Audience
items[i] = item
@@ -54,6 +53,7 @@ func (h *handler) list(c *gin.Context) {
Data: items,
Pagination: pagination,
})
return nil
}
// get godoc
@@ -64,14 +64,13 @@ func (h *handler) list(c *gin.Context) {
// @Param id path string true "API ID"
// @Success 200 {object} apiResponseDto
// @Router /api/apis/{id} [get]
func (h *handler) get(c *gin.Context) {
func (h *handler) get(c *gin.Context) error {
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
if err != nil {
_ = c.Error(err)
return
return err
}
h.respond(c, http.StatusOK, api)
return h.respond(c, http.StatusOK, api)
}
// create godoc
@@ -83,20 +82,18 @@ func (h *handler) get(c *gin.Context) {
// @Param api body apiCreateDto true "API information"
// @Success 201 {object} apiResponseDto "Created API"
// @Router /api/apis [post]
func (h *handler) create(c *gin.Context) {
func (h *handler) create(c *gin.Context) error {
var input apiCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
api, err := h.service.Create(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
return err
}
h.respond(c, http.StatusCreated, api)
return h.respond(c, http.StatusCreated, api)
}
// update godoc
@@ -109,20 +106,18 @@ func (h *handler) create(c *gin.Context) {
// @Param api body apiUpdateDto true "API information"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id} [put]
func (h *handler) update(c *gin.Context) {
func (h *handler) update(c *gin.Context) error {
var input apiUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
h.respond(c, http.StatusOK, api)
return h.respond(c, http.StatusOK, api)
}
// delete godoc
@@ -132,13 +127,13 @@ func (h *handler) update(c *gin.Context) {
// @Param id path string true "API ID"
// @Success 204 "No Content"
// @Router /api/apis/{id} [delete]
func (h *handler) delete(c *gin.Context) {
func (h *handler) delete(c *gin.Context) error {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updatePermissions godoc
@@ -151,20 +146,18 @@ func (h *handler) delete(c *gin.Context) {
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id}/permissions [put]
func (h *handler) updatePermissions(c *gin.Context) {
func (h *handler) updatePermissions(c *gin.Context) error {
var input apiPermissionsUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
h.respond(c, http.StatusOK, api)
return h.respond(c, http.StatusOK, api)
}
// getClientAccess godoc
@@ -175,14 +168,14 @@ func (h *handler) updatePermissions(c *gin.Context) {
// @Param clientId path string true "OIDC Client ID"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [get]
func (h *handler) getClientAccess(c *gin.Context) {
func (h *handler) getClientAccess(c *gin.Context) error {
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, newClientApiAccessDto(access))
return nil
}
// updateClientAccess godoc
@@ -195,21 +188,20 @@ func (h *handler) getClientAccess(c *gin.Context) {
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [put]
func (h *handler) updateClientAccess(c *gin.Context) {
func (h *handler) updateClientAccess(c *gin.Context) error {
var input clientApiAccessUpdateDto
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
return nil
}
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
@@ -224,12 +216,12 @@ func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
return dto
}
func (h *handler) respond(c *gin.Context, status int, api API) {
func (h *handler) respond(c *gin.Context, status int, api API) error {
var responseDto apiResponseDto
if err := dto.MapStruct(api, &responseDto); err != nil {
_ = c.Error(err)
return
return err
}
responseDto.Resource = api.Audience
c.JSON(status, responseDto)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
)
@@ -63,16 +64,16 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
apis := apiGroup.Group("/apis")
apis.Use(adminAuth)
apis.GET("", m.handler.list)
apis.POST("", m.handler.create)
apis.GET("/:id", m.handler.get)
apis.PUT("/:id", m.handler.update)
apis.DELETE("/:id", m.handler.delete)
apis.PUT("/:id/permissions", m.handler.updatePermissions)
apis.GET("", httpserver.Handle(m.handler.list))
apis.POST("", httpserver.Handle(m.handler.create))
apis.GET("/:id", httpserver.Handle(m.handler.get))
apis.PUT("/:id", httpserver.Handle(m.handler.update))
apis.DELETE("/:id", httpserver.Handle(m.handler.delete))
apis.PUT("/:id/permissions", httpserver.Handle(m.handler.updatePermissions))
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
access := apiGroup.Group("/api-access")
access.Use(adminAuth)
access.GET("/:clientId", m.handler.getClientAccess)
access.PUT("/:clientId", m.handler.updateClientAccess)
access.GET("/:clientId", httpserver.Handle(m.handler.getClientAccess))
access.PUT("/:clientId", httpserver.Handle(m.handler.updateClientAccess))
}

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
@@ -81,13 +81,16 @@ func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err
Where("id = ?", id).
First(&api).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return API{}, apperror.NotFound("API")
}
return api, err
}
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
if isIssuerAudience(input.Resource, s.issuer) {
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
return API{}, apperror.InvalidField("resource", "reserved", "is reserved by Pocket ID and cannot be used for a custom API")
}
api = API{
@@ -98,7 +101,7 @@ func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err
err = s.db.WithContext(ctx).Create(&api).Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return API{}, &common.AlreadyInUseError{Property: "resource"}
return API{}, apperror.AlreadyInUse("resource")
}
return API{}, err
}
@@ -170,16 +173,17 @@ func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPer
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
seen := make(map[string]struct{}, len(input.Permissions))
for _, permission := range input.Permissions {
for index, permission := range input.Permissions {
field := fmt.Sprintf("permissions[%d].key", index)
if !isValidPermissionKey(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
return API{}, apperror.InvalidField(field, "invalid_format", "contains characters that are not valid in an OAuth scope")
}
if isPermissionKeyReserved(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
return API{}, apperror.InvalidField(field, "reserved", "is reserved by Pocket ID")
}
_, ok := seen[permission.Key]
if ok {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
return API{}, apperror.InvalidField(field, "duplicate", "is listed more than once")
}
seen[permission.Key] = struct{}{}
}
@@ -261,8 +265,17 @@ type ClientAPIAccess struct {
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
if err := ensureOIDCClientExists(ctx, tx, clientID); err != nil {
return ClientAPIAccess{}, err
}
var rows []OidcClientAllowedAPIPermission
err = s.db.WithContext(ctx).
err = tx.WithContext(ctx).
Where("oidc_client_id = ?", clientID).
Find(&rows).
Error
@@ -293,9 +306,7 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces
tx.Rollback()
}()
// Ensure the client exists so callers get a 404 for an unknown client
var client model.OidcClient
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
if err = ensureOIDCClientExists(ctx, tx, clientID); err != nil {
return ClientAPIAccess{}, err
}
@@ -337,6 +348,20 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces
return applied, nil
}
func ensureOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error {
var client model.OidcClient
err := db.WithContext(ctx).
Select("id").
Where("id = ?", clientID).
First(&client).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound("OIDC client")
}
return err
}
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {

View File

@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -22,7 +22,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) {
// The resource is unique.
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
require.ErrorIs(t, err, &common.AlreadyInUseError{})
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
desc := "Read orders"
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
@@ -59,7 +59,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) {
require.NoError(t, svc.Delete(t.Context(), created.ID))
_, err = svc.Get(t.Context(), nil, created.ID)
require.Error(t, err)
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}
func TestClientApiAccessAllowList(t *testing.T) {
@@ -121,7 +121,10 @@ func TestClientApiAccessAllowList(t *testing.T) {
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
require.Error(t, err)
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
_, err = svc.GetClientAPIAccess(t.Context(), "nope")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
@@ -177,8 +180,7 @@ func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
{Key: key, Name: "Reserved"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
}
}
@@ -195,8 +197,7 @@ func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
{Key: "read:orders", Name: "Read again"},
}})
require.Error(t, err)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
}
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
@@ -212,8 +213,7 @@ func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
{Key: key, Name: "Invalid"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
}
// A valid scope-token key is accepted
@@ -232,8 +232,7 @@ func TestCreateRejectsIssuerResource(t *testing.T) {
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
require.Error(t, err, "resource %q must be rejected", resource)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
}
// A normal resource is accepted

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -27,27 +28,26 @@ func newHandler(service *Service) *handler {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiKeyDto]
// @Router /api/api-keys [get]
func (h *handler) list(c *gin.Context) {
func (h *handler) list(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
var apiKeysDto []apiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
return nil
}
// create godoc
@@ -57,31 +57,29 @@ func (h *handler) list(c *gin.Context) {
// @Param api_key body apiKeyCreateDto true "API key information"
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (h *handler) create(c *gin.Context) {
func (h *handler) create(c *gin.Context) error {
userID := c.GetString("userID")
var input apiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
return nil
}
// renew godoc
@@ -91,32 +89,30 @@ func (h *handler) create(c *gin.Context) {
// @Param id path string true "API Key ID"
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (h *handler) renew(c *gin.Context) {
func (h *handler) renew(c *gin.Context) error {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
var input apiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = c.Error(err)
return
return err
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
return nil
}
// revoke godoc
@@ -126,14 +122,14 @@ func (h *handler) renew(c *gin.Context) {
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (h *handler) revoke(c *gin.Context) {
func (h *handler) revoke(c *gin.Context) error {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -35,10 +36,10 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
group := apiGroup.Group("/api-keys")
group.GET("", auth, m.handler.list)
group.POST("", authWithoutApiKey, m.handler.create)
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
group.DELETE("/:id", auth, m.handler.revoke)
group.GET("", auth, httpserver.Handle(m.handler.list))
group.POST("", authWithoutApiKey, httpserver.Handle(m.handler.create))
group.POST("/:id/renew", authWithoutApiKey, httpserver.Handle(m.handler.renew))
group.DELETE("/:id", auth, httpserver.Handle(m.handler.revoke))
}
// ValidateApiKey resolves the user that owns the given raw API key

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -54,7 +55,7 @@ func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOpt
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
// Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
}
// Generate a secure random API key
@@ -77,7 +78,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
return ApiKey{}, "", apperror.AlreadyInUse("API key name")
}
return ApiKey{}, "", err
}
@@ -89,7 +90,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
}
tx := s.db.Begin()
@@ -105,14 +106,14 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ApiKey{}, "", &common.APIKeyNotFoundError{}
return ApiKey{}, "", apperror.APIKeyNotFound()
}
return ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
return ApiKey{}, "", apperror.APIKeyNotExpired()
}
// Generate a secure random API key
@@ -138,16 +139,15 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey ApiKey
err := s.db.
result := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
Delete(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.APIKeyNotFoundError{}
}
return err
Delete(&apiKey)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return apperror.APIKeyNotFound()
}
return nil
@@ -155,7 +155,7 @@ func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) err
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" {
return model.User{}, &common.NoAPIKeyProvidedError{}
return model.User{}, apperror.NoAPIKeyProvided()
}
if s.staticApiKey != "" && apiKey == s.staticApiKey {
@@ -179,7 +179,7 @@ func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{}
return model.User{}, apperror.InvalidAPIKey()
}
return model.User{}, err

View File

@@ -0,0 +1,21 @@
package apikey
import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/require"
)
func TestMissingManagedAPIKeyReturnsNotFound(t *testing.T) {
service, err := newService(t.Context(), testutils.NewDatabaseForTest(t), "")
require.NoError(t, err)
err = service.RevokeApiKey(t.Context(), "user-id", "missing-key")
require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound))
_, _, err = service.RenewApiKey(t.Context(), "user-id", "missing-key", time.Now().Add(time.Hour))
require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound))
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
@@ -117,18 +118,18 @@ func (s *AppConfigService) GetCIMDURLAllowlist() []string {
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) {
// If the UI config is disabled, we cannot continue
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
return nil, apperror.UIConfigDisabled()
}
// Validate the CIMD URL allowlist patterns, if provided
if input.CIMDURLAllowlist != "" {
var patterns []string
if err := json.Unmarshal([]byte(input.CIMDURLAllowlist), &patterns); err != nil {
return nil, &common.InvalidCIMDURLPatternError{Pattern: input.CIMDURLAllowlist}
return nil, apperror.InvalidCIMDURLPattern(input.CIMDURLAllowlist)
}
for _, p := range patterns {
if err := utils.ValidateCallbackURLPattern(p); err != nil {
return nil, &common.InvalidCIMDURLPatternError{Pattern: p}
return nil, apperror.InvalidCIMDURLPattern(p)
}
}
}
@@ -154,7 +155,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
// If the UI config is disabled, we cannot continue
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
return apperror.UIConfigDisabled()
}
// Collect the key-value pairs into a map for the actor

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -199,14 +200,13 @@ func TestService_UpdateAppConfig(t *testing.T) {
assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls)
})
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(nil)
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"})
require.Error(t, err)
var target *common.UiConfigDisabledError
assert.ErrorAs(t, err, &target)
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
})
}
@@ -269,15 +269,14 @@ func TestService_UpdateAppConfigValues(t *testing.T) {
assert.Equal(t, *getDefaultConfig(), *cfg)
})
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) {
setUIConfigDisabled(t, true)
svc := NewTestAppConfigService(nil)
// An even number of arguments so the count check passes and we reach the UI-config check
err := svc.UpdateAppConfigValues(t.Context(), "appName", "X")
require.Error(t, err)
var target *common.UiConfigDisabledError
assert.ErrorAs(t, err, &target)
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
})
}

View File

@@ -0,0 +1,289 @@
package apperror
import (
"fmt"
"net/http"
)
// The constructors in this file keep public messages, statuses, and details in one place
func AlreadyInUse(property string) *Error {
return New(CodeAlreadyInUse, http.StatusConflict, property+" is already in use").WithDetail("property", property)
}
func NotFound(resource string) *Error {
return New(CodeNotFound, http.StatusNotFound, resource+" not found").WithDetail("resource", resource)
}
func InvalidRequestBody(cause error) *Error {
return Wrap(cause, CodeInvalidRequestBody, http.StatusBadRequest, "Request body is invalid")
}
func MissingField(field string) *Error {
return InvalidField(field, "required", "is required")
}
func InvalidField(field, code, message string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, fmt.Sprintf("%s %s", field, message)).WithFields([]FieldError{{
Field: field,
Code: code,
Message: message,
}})
}
func SetupNotAvailable() *Error {
return New(CodeSetupNotAvailable, http.StatusNotFound, "Not found")
}
func SetupAlreadyCompleted() *Error {
return New(CodeSetupAlreadyCompleted, http.StatusConflict, "Initial setup has already been completed")
}
func TokenInvalidOrExpired() *Error {
return New(CodeTokenInvalidOrExpired, http.StatusUnauthorized, "Token is invalid or expired")
}
func DeviceCodeInvalid() *Error {
return New(CodeDeviceCodeInvalid, http.StatusUnauthorized, "One-time access code must be used on the device it was generated for")
}
func TokenInvalid() *Error {
return New(CodeInvalidToken, http.StatusUnauthorized, "Token is invalid")
}
func OidcMissingAuthorization() *Error {
return New(CodeOidcMissingAuthorization, http.StatusForbidden, "Authorization is missing")
}
func OidcInvalidCallbackURL() *Error {
return New(CodeOidcInvalidCallbackURL, http.StatusBadRequest, "Callback URL is invalid and may need to be corrected by an administrator")
}
func InvalidCIMDURLPattern(pattern string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, "Metadata document URL pattern is invalid").
WithDetail("pattern", pattern).
WithFields([]FieldError{{
Field: "cimdUrlAllowlist",
Code: "invalid_value",
Message: "contains an invalid URL pattern",
}})
}
func UnsupportedFileType(expected string) *Error {
if expected == "" {
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File type is not supported")
}
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File must be of type "+expected).
WithDetail("expected_file_type", expected)
}
func FileTooLarge(maxSize string) *Error {
return New(CodeFileTooLarge, http.StatusRequestEntityTooLarge, fmt.Sprintf("File must not exceed %s", maxSize)).WithDetail("max_size", maxSize)
}
func NotSignedIn() *Error {
return New(CodeNotSignedIn, http.StatusUnauthorized, "You are not signed in")
}
func MissingPermission() *Error {
return New(CodeForbidden, http.StatusForbidden, "You don't have permission to perform this action")
}
func TooManyRequests() *Error {
return New(CodeRateLimited, http.StatusTooManyRequests, "Too many requests")
}
func UserNotFound() *Error {
return New(CodeUserNotFound, http.StatusNotFound, "User not found")
}
func InvalidImage(cause error) *Error {
return Wrap(cause, CodeInvalidImage, http.StatusBadRequest, "File is not a valid image")
}
func MissingSessionID() *Error {
return New(CodeMissingSessionID, http.StatusBadRequest, "Session ID is missing")
}
func InvalidWebAuthnSession() *Error {
return New(CodeInvalidWebAuthnSession, http.StatusBadRequest, "Your passkey request has expired")
}
func InvalidWebAuthnResponse(cause error) *Error {
return Wrap(cause, CodeInvalidWebAuthnResponse, http.StatusBadRequest, "We couldn't process the response from your passkey")
}
func WebAuthnAuthenticationFailed(cause error) *Error {
return Wrap(cause, CodeWebAuthnAuthenticationFailed, http.StatusUnauthorized, "We couldn't verify your passkey")
}
func PasskeyUserVerificationRequired(cause error) *Error {
return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again")
}
func ReservedClaim(key string) *Error {
return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)).
WithDetail("key", key).
WithFields([]FieldError{{
Field: "key",
Code: "reserved",
Message: "is reserved",
}})
}
func DuplicateClaim(key string) *Error {
return New(CodeDuplicateClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is already defined", key)).
WithDetail("key", key).
WithFields([]FieldError{{
Field: "key",
Code: "duplicate",
Message: "is listed more than once",
}})
}
func LdapDisabled() *Error {
return New(CodeLdapDisabled, http.StatusConflict, "LDAP is not enabled")
}
func LdapUserUpdate() *Error {
return New(CodeLdapUserUpdate, http.StatusForbidden, "LDAP users can't be updated")
}
func LdapUserGroupUpdate() *Error {
return New(CodeLdapUserGroupUpdate, http.StatusForbidden, "LDAP user groups can't be updated")
}
func OidcAccessDenied() *Error {
return New(CodeOidcAccessDenied, http.StatusForbidden, "You're not allowed to access this service")
}
func OidcInteractionNotFound() *Error {
return New(CodeNotFound, http.StatusNotFound, "OIDC interaction not found or expired").
WithDetail("resource", "OIDC interaction")
}
func OidcClientIDNotMatching() *Error {
return New(CodeOidcClientIDNotMatching, http.StatusBadRequest, "Client ID in request doesn't match client ID in token")
}
func UIConfigDisabled() *Error {
return New(CodeUIConfigDisabled, http.StatusForbidden, "The configuration can't be changed since the UI configuration is disabled")
}
func InvalidUserID() *Error {
return Validation([]FieldError{{
Field: "userId",
Code: "invalid_format",
Message: "must be a valid UUID",
}})
}
func OneTimeAccessDisabled() *Error {
return New(CodeOneTimeAccessDisabled, http.StatusForbidden, "One-time access is disabled")
}
func DeviceLoginRequestInvalidOrExpired() *Error {
return New(CodeDeviceLoginExpired, http.StatusUnauthorized, "Device login request is invalid or expired")
}
func DeviceLoginDenied() *Error {
return New(CodeDeviceLoginDenied, http.StatusForbidden, "Device login request was denied")
}
func InvalidAPIKey() *Error {
return New(CodeInvalidAPIKey, http.StatusUnauthorized, "API key is invalid")
}
func NoAPIKeyProvided() *Error {
return New(CodeNoAPIKeyProvided, http.StatusUnauthorized, "API key is missing")
}
func APIKeyNotFound() *Error {
return New(CodeAPIKeyNotFound, http.StatusNotFound, "API key not found")
}
func APIKeyNotExpired() *Error {
return New(CodeAPIKeyNotExpired, http.StatusConflict, "API key is not expired yet")
}
func InvalidAPIKeyExpiration() *Error {
return New(CodeInvalidAPIKeyExpiration, http.StatusBadRequest, "API key expiration time must be in the future").WithFields([]FieldError{{
Field: "expiresAt",
Code: "invalid_value",
Message: "must be in the future",
}})
}
func APIKeyAuthNotAllowed() *Error {
return New(CodeAPIKeyAuthNotAllowed, http.StatusForbidden, "API key authentication is not allowed for this endpoint")
}
func UserDisabled() *Error {
return New(CodeUserDisabled, http.StatusForbidden, "User account is disabled")
}
func ValidationMessage(message string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, message)
}
func OidcDeviceCodeExpired() *Error {
return New(CodeOidcDeviceCodeExpired, http.StatusBadRequest, "Device code has expired")
}
func OidcInvalidDeviceCode() *Error {
return New(CodeOidcInvalidDeviceCode, http.StatusBadRequest, "Device code is invalid")
}
func ReauthenticationRequired() *Error {
return New(CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
}
func ReauthenticationRequiredWithCause(cause error) *Error {
return Wrap(cause, CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
}
func OpenSignupDisabled() *Error {
return New(CodeOpenSignupDisabled, http.StatusForbidden, "Open user signup is not enabled")
}
func ClientIDAlreadyExists() *Error {
return New(CodeClientIDAlreadyExists, http.StatusConflict, "Client ID is already in use")
}
func UserEmailNotSet() *Error {
return New(CodeUserEmailNotSet, http.StatusConflict, "The user does not have an email address set")
}
func ImageNotFound() *Error {
return New(CodeImageNotFound, http.StatusNotFound, "Image not found")
}
func LogoDownloadFailed(cause error) *Error {
return Wrap(cause, CodeLogoDownloadFailed, http.StatusUnprocessableEntity, "Logo could not be downloaded")
}
func LogoTypeNotSupported() *Error {
return New(CodeLogoTypeNotSupported, http.StatusUnprocessableEntity, "Downloaded logo has an unsupported file type")
}
func LogoTooLarge(maxSize string) *Error {
return New(CodeLogoTooLarge, http.StatusUnprocessableEntity, fmt.Sprintf("Downloaded logo must not exceed %s", maxSize)).
WithDetail("max_size", maxSize)
}
func InvalidLogoURL(cause error) *Error {
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
Field: "logoUrl",
Code: "invalid_value",
Message: "must be a public HTTP or HTTPS URL",
}})
}
func OidcPARRequired() *Error {
return New(CodeOidcPARRequired, http.StatusBadRequest, "This client requires pushed authorization requests")
}
func InvalidEmailVerificationToken() *Error {
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
}

View File

@@ -0,0 +1,234 @@
package apperror
import (
"errors"
"fmt"
"maps"
"net/http"
"time"
)
type Code string
// #nosec G101 -- these are client-visible error codes, not credentials
const (
CodeInternal Code = "internal_error"
CodeValidationFailed Code = "validation_failed"
CodeInvalidRequestBody Code = "invalid_request_body"
CodeRequestTimeout Code = "request_timeout"
CodeNotFound Code = "not_found"
CodeAlreadyInUse Code = "already_in_use"
CodeForbidden Code = "forbidden"
CodeInvalidToken Code = "invalid_token"
CodeRateLimited Code = "rate_limited"
CodeFileTooLarge Code = "file_too_large"
CodeInvalidImage Code = "invalid_image"
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
CodeUserNotFound Code = "user_not_found"
CodeUserDisabled Code = "user_disabled"
CodeAPIKeyNotFound Code = "api_key_not_found"
CodeInvalidAPIKey Code = "invalid_api_key"
CodeDeviceLoginExpired Code = "device_login_expired"
CodeReauthenticationRequired Code = "reauthentication_required"
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
CodeSetupNotAvailable Code = "setup_not_available"
CodeSetupAlreadyCompleted Code = "setup_already_completed"
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
CodeDeviceCodeInvalid Code = "device_code_invalid"
CodeOidcMissingAuthorization Code = "oidc_missing_authorization"
CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url"
CodeFileTypeNotSupported Code = "file_type_not_supported"
CodeNotSignedIn Code = "not_signed_in"
CodeMissingSessionID Code = "missing_session_id"
CodeReservedClaim Code = "reserved_claim"
CodeDuplicateClaim Code = "duplicate_claim"
CodeLdapDisabled Code = "ldap_disabled"
CodeLdapUserUpdate Code = "ldap_user_update"
CodeLdapUserGroupUpdate Code = "ldap_user_group_update"
CodeOidcAccessDenied Code = "oidc_access_denied"
CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching"
CodeUIConfigDisabled Code = "ui_config_disabled"
CodeOneTimeAccessDisabled Code = "one_time_access_disabled"
CodeDeviceLoginDenied Code = "device_login_denied"
CodeNoAPIKeyProvided Code = "no_api_key_provided"
CodeAPIKeyNotExpired Code = "api_key_not_expired"
CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration"
CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed"
CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired"
CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code"
CodeOpenSignupDisabled Code = "open_signup_disabled"
CodeClientIDAlreadyExists Code = "client_id_already_exists"
CodeUserEmailNotSet Code = "user_email_not_set"
CodeImageNotFound Code = "image_not_found"
CodeLogoDownloadFailed Code = "logo_download_failed"
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
CodeLogoTooLarge Code = "logo_too_large"
CodeOidcPARRequired Code = "oidc_par_required"
)
// FieldError describes one safe, client-actionable validation failure
type FieldError struct {
Field string `json:"field"`
Code string `json:"code"`
Message string `json:"message"`
}
// Error is an application error with a stable code, an HTTP status, and an optional internal cause
type Error struct {
code Code
status int
message string
details map[string]string
fields []FieldError
retryAfter time.Duration
cause error
}
// New creates a client-safe application error
func New(code Code, status int, message string) *Error {
return &Error{code: code, status: status, message: message}
}
// Wrap creates a client-safe application error that retains an internal cause
func Wrap(cause error, code Code, status int, message string) *Error {
if cause == nil {
return New(code, status, message)
}
return &Error{code: code, status: status, message: message, cause: cause}
}
// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients
func Internal(cause error) *Error {
return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong")
}
// Validation creates a structured validation error
func Validation(fields []FieldError) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
}
// Error returns diagnostic text including the internal cause when one exists
func (e *Error) Error() string {
if e == nil {
return "<nil>"
}
if e.cause == nil {
return e.message
}
return fmt.Sprintf("%s: %v", e.message, e.cause)
}
// Unwrap exposes the internal cause to errors.Is and errors.As
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// Is matches application errors by stable code while ignoring message and detail differences
func (e *Error) Is(target error) bool {
targetError, ok := target.(*Error)
return ok && e != nil && targetError != nil && e.code == targetError.code
}
// IsCode reports whether an error or one of its wrapped causes has the given application code
func IsCode(err error, code Code) bool {
return errors.Is(err, &Error{code: code})
}
// Code returns the stable application error code
func (e *Error) Code() Code {
if e == nil {
return ""
}
return e.code
}
// HTTPStatus returns the HTTP status associated with the error
func (e *Error) HTTPStatus() int {
if e == nil {
return http.StatusInternalServerError
}
return e.status
}
// ClientMessage returns the message that is safe to include in an HTTP response
func (e *Error) ClientMessage() string {
if e == nil {
return ""
}
return e.message
}
// Details returns a copy of the additional client-safe details
func (e *Error) Details() map[string]string {
if e == nil || len(e.details) == 0 {
return nil
}
details := make(map[string]string, len(e.details))
maps.Copy(details, e.details)
return details
}
// Fields returns a copy of the structured validation details
func (e *Error) Fields() []FieldError {
if e == nil || len(e.fields) == 0 {
return nil
}
return append([]FieldError(nil), e.fields...)
}
// RetryAfter returns the duration a client should wait before retrying
func (e *Error) RetryAfter() time.Duration {
if e == nil {
return 0
}
return e.retryAfter
}
// WithFields attaches structured validation details without exposing the cause
func (e *Error) WithFields(fields []FieldError) *Error {
if e == nil {
return nil
}
e.fields = append([]FieldError(nil), fields...)
return e
}
// WithDetail attaches one client-safe string detail
func (e *Error) WithDetail(key, value string) *Error {
if e == nil {
return nil
}
if e.details == nil {
e.details = make(map[string]string)
}
e.details[key] = value
return e
}
// WithRetryAfter attaches a retry delay to the error
func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error {
if e == nil {
return nil
}
e.retryAfter = retryAfter
return e
}

View File

@@ -0,0 +1,45 @@
package apperror
import (
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestWrapPreservesCauseWithoutChangingClientMessage(t *testing.T) {
cause := errors.New("database connection details")
err := Wrap(cause, CodeNotFound, http.StatusNotFound, "User not found")
require.ErrorIs(t, err, cause)
require.Equal(t, "User not found", err.ClientMessage())
require.Contains(t, err.Error(), "database connection details")
}
func TestErrorsMatchByCode(t *testing.T) {
err := Wrap(errors.New("database connection details"), CodeAlreadyInUse, http.StatusBadRequest, "email is already in use")
wrapped := Wrap(err, CodeInternal, http.StatusInternalServerError, "request failed")
require.True(t, IsCode(err, CodeAlreadyInUse))
require.True(t, IsCode(wrapped, CodeAlreadyInUse))
require.ErrorIs(t, err, New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use"))
require.NotErrorIs(t, err, errors.Join(New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use")))
require.False(t, IsCode(wrapped, CodeNotFound))
}
func TestErrorCopiesFields(t *testing.T) {
fields := []FieldError{{Field: "email", Code: "required", Message: "is required"}}
err := New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
fields[0].Message = "changed"
require.Equal(t, "is required", err.Fields()[0].Message)
}
func TestErrorCopiesDetails(t *testing.T) {
err := New(CodeAlreadyInUse, http.StatusBadRequest, "Already in use").WithDetail("property", "email")
details := err.Details()
details["property"] = "password"
require.Equal(t, map[string]string{"property": "email"}, err.Details())
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/go-kit/servicerunner"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/frontend"
@@ -404,6 +405,12 @@ func initLogger(r *gin.Engine) {
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
return slog.Default()
}),
sloggin.WithClientErrorLevel(slog.LevelInfo),
sloggin.WithSpecificLogLevelByStatusCode(map[int]slog.Level{
http.StatusTooManyRequests: slog.LevelWarn,
}),
sloggin.WithContext(enrichRequestLog),
// Skip logging for certain paths to reduce noise in the logs
sloggin.WithSkipper(func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
@@ -415,6 +422,33 @@ func initLogger(r *gin.Engine) {
))
}
// enrichRequestLog enriches the slog.Record with additional attributes from the request context, such as request ID, error code, and trace/span IDs.
func enrichRequestLog(c *gin.Context, record *slog.Record) *slog.Record {
enriched := slog.NewRecord(record.Time, record.Level, "HTTP request completed", record.PC)
// Add request ID if present in the context
if requestID := middleware.RequestID(c); requestID != "" {
enriched.AddAttrs(slog.String("request_id", requestID))
}
// Add error code if present in the context
if errorCode := middleware.RequestErrorCode(c); errorCode != "" {
enriched.AddAttrs(slog.String("error_code", string(errorCode)))
}
// Add trace and span IDs if present in the context
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
enriched.AddAttrs(
slog.String("trace_id", spanContext.TraceID().String()),
slog.String("span_id", spanContext.SpanID().String()),
)
}
record.Attrs(func(attr slog.Attr) bool {
enriched.AddAttrs(attr)
return true
})
return &enriched
}
// tlsCertProvider holds certificates that can be dynamically reloaded
type tlsCertProvider struct {
certMutex sync.RWMutex

View File

@@ -0,0 +1,80 @@
package bootstrap
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/stretchr/testify/require"
)
func TestRequestLoggerUsesStructuredErrorMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
var output bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
t.Cleanup(func() {
slog.SetDefault(previousLogger)
})
router := gin.New()
initLogger(router)
router.Use(middleware.NewErrorHandlerMiddleware().Add())
router.GET("/api/users/me", func(c *gin.Context) {
_ = c.Error(apperror.NotSignedIn())
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users/me", nil)
router.ServeHTTP(recorder, request)
var body struct {
RequestID string `json:"request_id"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.NotEmpty(t, body.RequestID)
logLine := output.String()
require.Contains(t, logLine, "level=INFO")
require.Contains(t, logLine, `msg="HTTP request completed"`)
require.Contains(t, logLine, "request_id="+body.RequestID)
require.Contains(t, logLine, "error_code=not_signed_in")
require.Contains(t, logLine, "status=401")
require.NotContains(t, logLine, "Request with errors")
require.NotContains(t, logLine, "Error #01")
}
func TestRequestLoggerKeepsRateLimitsAtWarningLevel(t *testing.T) {
gin.SetMode(gin.TestMode)
var output bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
t.Cleanup(func() {
slog.SetDefault(previousLogger)
})
router := gin.New()
initLogger(router)
router.Use(middleware.NewErrorHandlerMiddleware().Add())
router.GET("/api/limited", func(c *gin.Context) {
_ = c.Error(apperror.TooManyRequests())
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/limited", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
require.Contains(t, output.String(), "level=WARN")
require.Contains(t, output.String(), "error_code=rate_limited")
}

View File

@@ -328,7 +328,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)

View File

@@ -1,309 +0,0 @@
package common
import (
"errors"
"fmt"
"net/http"
)
type AppError interface {
error
HttpStatusCode() int
}
type AppErrorDescription interface {
AppError
Description() string
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e AlreadyInUseError) Error() string {
return e.Property + " is already in use"
}
func (e AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
func (e AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
x := &AlreadyInUseError{}
return errors.As(target, &x)
}
type SetupNotAvailableError struct{}
func (e SetupNotAvailableError) Error() string { return "not found" }
func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
type TokenInvalidOrExpiredError struct{}
func (e TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
type DeviceCodeInvalid struct{}
func (e DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for"
}
func (e DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
type TokenInvalidError struct{}
func (e TokenInvalidError) Error() string { return "Token is invalid" }
func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcMissingAuthorizationError struct{}
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCallbackURLError struct{}
func (e OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidCIMDURLPatternError struct {
Pattern string
}
func (e InvalidCIMDURLPatternError) Error() string {
return "invalid metadata document URL pattern: " + e.Pattern
}
func (e InvalidCIMDURLPatternError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTypeNotSupportedError struct{}
func (e FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTooLargeError struct {
MaxSize string
}
func (e FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e NotSignedInError) Error() string { return "You are not signed in" }
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e TooManyRequestsError) Error() string { return "Too many requests" }
func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type UserIdNotProvidedError struct{}
func (e UserIdNotProvidedError) Error() string { return "User id not provided" }
func (e UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type UserNotFoundError struct{}
func (e UserNotFoundError) Error() string { return "User not found" }
func (e UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type WrongFileTypeError struct {
ExpectedFileType string
}
func (e WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e MissingSessionIdError) Error() string { return "Missing session id" }
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidWebauthnSessionError struct{}
func (e InvalidWebauthnSessionError) Error() string {
return "WebAuthn session is invalid or has expired"
}
func (e InvalidWebauthnSessionError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
Key string
}
func (e ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
Key string
}
func (e DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" }
func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type OidcAccessDeniedError struct{}
func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" }
func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled"
}
func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e InvalidUUIDError) Error() string { return "Invalid UUID" }
func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
type OneTimeAccessDisabledError struct{}
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
type DeviceLoginRequestInvalidOrExpiredError struct{}
func (e DeviceLoginRequestInvalidOrExpiredError) Error() string {
return "Device login request is invalid or expired"
}
func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
type DeviceLoginDeniedError struct{}
func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" }
func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidAPIKeyError struct{}
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
type NoAPIKeyProvidedError struct{}
func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" }
func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotFoundError struct{}
func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" }
func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotExpiredError struct{}
func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" }
func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyExpirationDateError struct{}
func (e APIKeyExpirationDateError) Error() string {
return "API Key expiration time must be in the future"
}
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyAuthNotAllowedError struct{}
func (e APIKeyAuthNotAllowedError) Error() string {
return "API key authentication is not allowed for this endpoint"
}
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type UserDisabledError struct{}
func (e UserDisabledError) Error() string { return "User account is disabled" }
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type ValidationError struct{ Message string }
func (e ValidationError) Error() string { return e.Message }
func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcDeviceCodeExpiredError struct{}
func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" }
func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidDeviceCodeError struct{}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type ReauthenticationRequiredError struct{}
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized }
type OpenSignupDisabledError struct{}
func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" }
func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type ClientIdAlreadyExistsError struct{}
func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" }
func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest }
type UserEmailNotSetError struct{}
func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" }
func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest }
type ImageNotFoundError struct{}
func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
return "this client requires pushed authorization requests"
}
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -9,6 +9,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
@@ -35,12 +36,12 @@ func NewAppConfigController(
emailSender: emailSender,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
group.GET("/application-configuration", httpserver.Handle(acc.listAppConfigHandler))
group.GET("/application-configuration/all", authMiddleware.Add(), httpserver.Handle(acc.listAllAppConfigHandler))
group.PUT("/application-configuration", authMiddleware.Add(), httpserver.Handle(acc.updateAppConfigHandler))
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
group.POST("/application-configuration/test-email", authMiddleware.Add(), httpserver.Handle(acc.testEmailHandler))
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), httpserver.Handle(acc.syncLdapHandler))
}
type AppConfigController struct {
@@ -57,18 +58,16 @@ type AppConfigController struct {
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Router /api/application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
configuration := dbConfig.ToAppConfigVariableSlice(false, true)
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
_ = c.Error(err)
return
return err
}
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
@@ -85,6 +84,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
})
c.JSON(http.StatusOK, configVariablesDto)
return nil
}
// listAllAppConfigHandler godoc
@@ -95,21 +95,20 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) error {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
configuration := dbConfig.ToAppConfigVariableSlice(true, true)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, configVariablesDto)
return nil
}
// updateAppConfigHandler godoc
@@ -121,26 +120,24 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) error {
var input dto.AppConfigUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, configVariablesDto)
return nil
}
// syncLdapHandler godoc
@@ -149,20 +146,19 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) error {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// testEmailHandler godoc
@@ -171,20 +167,19 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
func (acc *AppConfigController) testEmailHandler(c *gin.Context) error {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
userID := c.GetString("userID")
err = acc.emailSender.SendTestEmail(c.Request.Context(), dbConfig, userID)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -9,7 +9,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -24,20 +25,20 @@ func NewAppImagesController(
appImagesService: appImagesService,
}
group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/email", controller.getEmailLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.GET("/application-images/logo", httpserver.Handle(controller.getLogoHandler))
group.GET("/application-images/email", httpserver.Handle(controller.getEmailLogoHandler))
group.GET("/application-images/background", httpserver.Handle(controller.getBackgroundImageHandler))
group.GET("/application-images/favicon", httpserver.Handle(controller.getFaviconHandler))
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.getDefaultProfilePicture))
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), httpserver.Handle(controller.updateLogoHandler))
group.PUT("/application-images/email", authMiddleware.Add(), httpserver.Handle(controller.updateEmailLogoHandler))
group.PUT("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.updateBackgroundImageHandler))
group.PUT("/application-images/favicon", authMiddleware.Add(), httpserver.Handle(controller.updateFaviconHandler))
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.updateDefaultProfilePicture))
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
group.DELETE("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.deleteBackgroundImageHandler))
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.deleteDefaultProfilePicture))
}
type AppImagesController struct {
@@ -54,14 +55,14 @@ type AppImagesController struct {
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) error {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
c.getImage(ctx, imageName)
return c.getImage(ctx, imageName)
}
// getEmailLogoHandler godoc
@@ -72,8 +73,8 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
// @Produce image/jpeg
// @Success 200 {file} binary "Email logo image"
// @Router /api/application-images/email [get]
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
c.getImage(ctx, "logoEmail")
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) error {
return c.getImage(ctx, "logoEmail")
}
// getBackgroundImageHandler godoc
@@ -84,8 +85,8 @@ func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-images/background [get]
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
c.getImage(ctx, "background")
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) error {
return c.getImage(ctx, "background")
}
// getFaviconHandler godoc
@@ -95,8 +96,8 @@ func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-images/favicon [get]
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon")
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) error {
return c.getImage(ctx, "favicon")
}
// getDefaultProfilePicture godoc
@@ -107,8 +108,8 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) error {
return c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc
@@ -120,11 +121,10 @@ func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/logo [put]
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
file, err := httpserver.FormFile(ctx, "file")
if err != nil {
_ = ctx.Error(err)
return
return err
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
@@ -134,11 +134,11 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
// updateEmailLogoHandler godoc
@@ -149,27 +149,25 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
// @Param file formData file true "Email logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/email [put]
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) error {
file, err := httpserver.FormFile(ctx, "file")
if err != nil {
_ = ctx.Error(err)
return
return err
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType != "image/png" && mimeType != "image/jpeg" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
return apperror.UnsupportedFileType("PNG or JPEG")
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
// updateBackgroundImageHandler godoc
@@ -180,19 +178,18 @@ func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-images/background [put]
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) error {
file, err := httpserver.FormFile(ctx, "file")
if err != nil {
_ = ctx.Error(err)
return
return err
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
// deleteBackgroundImageHandler godoc
@@ -201,13 +198,13 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/background [delete]
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) error {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
// updateFaviconHandler godoc
@@ -218,39 +215,37 @@ func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) error {
file, err := httpserver.FormFile(ctx, "file")
if err != nil {
_ = ctx.Error(err)
return
return err
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
return
return apperror.UnsupportedFileType("SVG, PNG, or ICO")
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
func (c *AppImagesController) getImage(ctx *gin.Context, name string) error {
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
if err != nil {
_ = ctx.Error(err)
return
return err
}
defer reader.Close()
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
return nil
}
// updateDefaultProfilePicture godoc
@@ -261,19 +256,18 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) error {
file, err := httpserver.FormFile(ctx, "file")
if err != nil {
_ = ctx.Error(err)
return
return err
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
// deleteDefaultProfilePicture godoc
@@ -282,11 +276,11 @@ func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) error {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -20,10 +21,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi
auditLogService: auditLogService,
}
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
group.GET("/audit-logs/all", authMiddleware.Add(), httpserver.Handle(alc.listAllAuditLogsHandler))
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(alc.listAuditLogsForUserHandler))
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), httpserver.Handle(alc.listClientNamesHandler))
group.GET("/audit-logs/filters/users", authMiddleware.Add(), httpserver.Handle(alc.listUserNamesWithIdsHandler))
}
type AuditLogController struct {
@@ -40,7 +41,7 @@ type AuditLogController struct {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
@@ -48,16 +49,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
_ = c.Error(err)
return
return err
}
// Add device information to the logs
@@ -71,6 +70,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
Data: logsDtos,
Pagination: pagination,
})
return nil
}
// listAllAuditLogsHandler godoc
@@ -83,20 +83,18 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
_ = c.Error(err)
return
return err
}
for i, logsDto := range logsDtos {
@@ -110,6 +108,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
Data: logsDtos,
Pagination: pagination,
})
return nil
}
// listClientNamesHandler godoc
@@ -118,14 +117,14 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
// @Tags Audit Logs
// @Success 200 {array} string "List of client names"
// @Router /api/audit-logs/filters/client-names [get]
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) error {
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, names)
return nil
}
// listUserNamesWithIdsHandler godoc
@@ -134,12 +133,12 @@ func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
// @Tags Audit Logs
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
// @Router /api/audit-logs/filters/users [get]
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) error {
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, users)
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -19,9 +20,9 @@ func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware
customClaimsGroup := group.Group("/custom-claims")
customClaimsGroup.Use(authMiddleware.Add())
{
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
customClaimsGroup.GET("/suggestions", httpserver.Handle(wkc.getSuggestionsHandler))
customClaimsGroup.PUT("/user/:userId", httpserver.Handle(wkc.UpdateCustomClaimsForUserHandler))
customClaimsGroup.PUT("/user-group/:userGroupId", httpserver.Handle(wkc.UpdateCustomClaimsForUserGroupHandler))
}
}
@@ -36,14 +37,14 @@ type CustomClaimController struct {
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) error {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, claims)
return nil
}
// UpdateCustomClaimsForUserHandler godoc
@@ -56,28 +57,26 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) error {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, customClaimsDto)
return nil
}
// UpdateCustomClaimsForUserGroupHandler godoc
@@ -90,26 +89,24 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) error {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, customClaimsDto)
return nil
}

View File

@@ -7,25 +7,26 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/accesstoken", testController.signAccessToken)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.POST("/test/reset", httpserver.Handle(testController.resetAndSeedHandler))
group.POST("/test/accesstoken", httpserver.Handle(testController.signAccessToken))
group.POST("/test/refreshtoken", httpserver.Handle(testController.signRefreshToken))
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
group.POST("/externalidp/sign", testController.externalIdPSignToken)
group.GET("/externalidp/jwks.json", httpserver.Handle(testController.externalIdPJWKS))
group.POST("/externalidp/sign", httpserver.Handle(testController.externalIdPSignToken))
}
type TestController struct {
TestService *service.TestService
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) error {
var baseURL string
if c.Request.TLS != nil {
baseURL = "https://" + c.Request.Host
@@ -37,111 +38,103 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
skipSeed := c.Query("skip-seed") == "true"
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
return
return err
}
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
return err
}
if !skipSeed {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
return err
}
}
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
return err
}
if !skipLdap {
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
return err
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
return err
}
}
c.Status(http.StatusNoContent)
return nil
}
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
func (tc *TestController) externalIdPJWKS(c *gin.Context) error {
jwks, err := tc.TestService.GetExternalIdPJWKS()
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, jwks)
return nil
}
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
func (tc *TestController) externalIdPSignToken(c *gin.Context) error {
var input struct {
Aud string `json:"aud"`
Iss string `json:"iss"`
Sub string `json:"sub"`
}
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Writer.WriteString(token)
return nil
}
func (tc *TestController) signAccessToken(c *gin.Context) {
func (tc *TestController) signAccessToken(c *gin.Context) error {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
Expired bool `json:"expired"`
}
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Writer.WriteString(token)
return nil
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
func (tc *TestController) signRefreshToken(c *gin.Context) error {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
RefreshToken string `json:"rt"`
}
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Writer.WriteString(token)
return nil
}

View File

@@ -1,8 +1,6 @@
package controller
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
@@ -10,8 +8,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -26,31 +25,31 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
oidcService: oidcService,
}
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), oc.refreshClientMetadataHandler)
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
group.GET("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.listClientsHandler))
group.POST("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.createClientHandler))
group.GET("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.getClientHandler))
group.GET("/oidc/clients/:id/meta", httpserver.Handle(oc.getClientMetaDataHandler))
group.PUT("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.updateClientHandler))
group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), httpserver.Handle(oc.refreshClientMetadataHandler))
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.deleteClientHandler))
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), httpserver.Handle(oc.updateAllowedUserGroupsHandler))
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), httpserver.Handle(oc.createClientSecretHandler))
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/logo", httpserver.Handle(oc.getClientLogoHandler))
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), httpserver.Handle(oc.deleteClientLogoHandler))
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), httpserver.Handle(oc.updateClientLogoHandler))
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), httpserver.Handle(oc.getClientPreviewHandler))
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAuthorizedClientsHandler))
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), httpserver.Handle(oc.listAuthorizedClientsHandler))
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.revokeOwnClientAuthorizationHandler))
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAccessibleClientsHandler))
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), httpserver.Handle(oc.getClientScimServiceProviderHandler))
}
@@ -66,22 +65,21 @@ type OidcController struct {
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
// @Router /api/oidc/clients/{id}/meta [get]
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) error {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil {
_ = c.Error(err)
return
return err
}
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
if err := dto.MapStruct(client, &clientDto); err != nil {
return err
}
_ = c.Error(err)
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return nil
}
// getClientHandler godoc
@@ -92,22 +90,21 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
func (oc *OidcController) getClientHandler(c *gin.Context) error {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil {
_ = c.Error(err)
return
return err
}
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, clientDto)
return nil
}
// listClientsHandler godoc
@@ -121,14 +118,13 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
func (oc *OidcController) listClientsHandler(c *gin.Context) error {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
// Map the user groups to DTOs
@@ -136,14 +132,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
return err
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
return
return err
}
clientsDto[i] = clientDto
}
@@ -152,6 +147,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
Data: clientsDto,
Pagination: pagination,
})
return nil
}
// createClientHandler godoc
@@ -163,26 +159,24 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
func (oc *OidcController) createClientHandler(c *gin.Context) error {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
return err
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, clientDto)
return nil
}
// deleteClientHandler godoc
@@ -192,14 +186,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
func (oc *OidcController) deleteClientHandler(c *gin.Context) error {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updateClientHandler godoc
@@ -212,26 +206,24 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Param client body dto.OidcClientUpdateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
func (oc *OidcController) updateClientHandler(c *gin.Context) error {
var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, clientDto)
return nil
}
// refreshClientMetadataHandler godoc
@@ -242,20 +234,20 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Refreshed client"
// @Router /api/oidc/clients/{id}/refresh [post]
func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) {
func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) error {
client, err := oc.oidcService.RefreshClientMetadata(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
return err
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
return err
}
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return nil
}
// createClientSecretHandler godoc
@@ -268,20 +260,19 @@ func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) {
// @Param payload body dto.OidcClientSecretDto false "Client secret"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) createClientSecretHandler(c *gin.Context) error {
var input dto.OidcClientSecretDto
if err := c.ShouldBindJSON(&input); err != nil && !errors.Is(err, io.EOF) {
_ = c.Error(err)
return
if err := httpserver.BindOptionalJSON(c, &input); err != nil {
return err
}
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, gin.H{"secret": secret})
return nil
}
// getClientLogoHandler godoc
@@ -295,13 +286,12 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) error {
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
if err != nil {
_ = c.Error(err)
return
return err
}
defer reader.Close()
@@ -309,6 +299,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
c.Header("Content-Type", mimeType)
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
return nil
}
// updateClientLogoHandler godoc
@@ -321,22 +312,21 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) error {
file, err := httpserver.FormFile(c, "file")
if err != nil {
_ = c.Error(err)
return
return err
}
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// deleteClientLogoHandler godoc
@@ -347,7 +337,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) error {
var err error
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
@@ -358,11 +348,11 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
}
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updateAllowedUserGroupsHandler godoc
@@ -375,27 +365,25 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
_ = c.Error(err)
return
return err
}
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
c.JSON(http.StatusOK, oidcClientDto)
return nil
}
// listOwnAuthorizedClientsHandler godoc
@@ -408,9 +396,9 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
return oc.listAuthorizedClients(c, userID)
}
// listAuthorizedClientsHandler godoc
@@ -424,31 +412,30 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
return oc.listAuthorizedClients(c, userID)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) error {
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
return nil
}
// revokeOwnClientAuthorizationHandler godoc
@@ -458,18 +445,18 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) error {
clientID := c.Param("clientId")
userID := c.GetString("userID")
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// listOwnAccessibleClientsHandler godoc
@@ -482,21 +469,21 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
return nil
}
// getClientPreviewHandler godoc
@@ -510,24 +497,21 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) error {
clientID := c.Param("id")
userID := c.Param("userId")
scopes := c.Query("scopes")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
return
return apperror.MissingField("clientId")
}
if userID == "" {
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
return
return apperror.MissingField("userId")
}
if scopes == "" {
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
return
return apperror.MissingField("scopes")
}
preview, err := oc.oidcService.GetClientPreview(
@@ -538,11 +522,11 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
c.GetString("authenticationMethod"))
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, preview)
return nil
}
// getClientScimServiceProviderHandler godoc
@@ -553,20 +537,19 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) error {
clientID := c.Param("id")
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
if err != nil {
_ = c.Error(err)
return
return err
}
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, providerDto)
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -14,10 +15,10 @@ func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
scimService: scimService,
}
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
group.POST("/scim/service-provider", authMiddleware.Add(), httpserver.Handle(ugc.createServiceProviderHandler))
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), httpserver.Handle(ugc.syncServiceProviderHandler))
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.updateServiceProviderHandler))
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.deleteServiceProviderHandler))
}
type ScimController struct {
@@ -31,14 +32,14 @@ type ScimController struct {
// @Param id path string true "Service Provider ID"
// @Success 200 "OK"
// @Router /api/scim/service-provider/{id}/sync [post]
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) error {
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusOK)
return nil
}
// createServiceProviderHandler godoc
@@ -50,26 +51,24 @@ func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
// @Router /api/scim/service-provider [post]
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) error {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
if err := httpserver.BindJSON(ctx, &input); err != nil {
return err
}
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
if err != nil {
_ = ctx.Error(err)
return
return err
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.JSON(http.StatusCreated, providerDTO)
return nil
}
// updateServiceProviderHandler godoc
@@ -82,26 +81,24 @@ func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
// @Router /api/scim/service-provider/{id} [put]
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) error {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
if err := httpserver.BindJSON(ctx, &input); err != nil {
return err
}
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
if err != nil {
_ = ctx.Error(err)
return
return err
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.JSON(http.StatusOK, providerDTO)
return nil
}
// deleteServiceProviderHandler godoc
@@ -111,12 +108,12 @@ func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
// @Param id path string true "Service Provider ID"
// @Success 204 "No Content"
// @Router /api/scim/service-provider/{id} [delete]
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) error {
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
return err
}
ctx.Status(http.StatusNoContent)
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -26,26 +27,26 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
webAuthnService: webAuthnService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
group.GET("/users", authMiddleware.Add(), httpserver.Handle(uc.listUsersHandler))
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.getCurrentUserHandler))
group.GET("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.getUserHandler))
group.POST("/users", authMiddleware.Add(), httpserver.Handle(uc.createUserHandler))
group.PUT("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.updateUserHandler))
group.GET("/users/:id/groups", authMiddleware.Add(), httpserver.Handle(uc.getUserGroupsHandler))
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), httpserver.Handle(uc.listUserWebauthnCredentialsHandler))
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserHandler))
group.DELETE("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.deleteUserHandler))
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), httpserver.Handle(uc.deleteUserWebauthnCredentialHandler))
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
group.PUT("/users/:id/user-groups", authMiddleware.Add(), httpserver.Handle(uc.updateUserGroups))
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/:id/profile-picture.png", httpserver.Handle(uc.getUserProfilePictureHandler))
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.updateUserProfilePictureHandler))
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserProfilePictureHandler))
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.resetUserProfilePictureHandler))
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.resetCurrentUserProfilePictureHandler))
}
type UserController struct {
@@ -61,21 +62,20 @@ type UserController struct {
// @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDto
// @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
func (uc *UserController) getUserGroupsHandler(c *gin.Context) error {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
return err
}
var groupsDto []dto.UserGroupDto
if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, groupsDto)
return nil
}
// listUserWebauthnCredentialsHandler godoc
@@ -85,27 +85,25 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Param id path string true "User ID"
// @Success 200 {array} dto.WebauthnCredentialDto
// @Router /api/users/{id}/webauthn-credentials [get]
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) error {
userID := c.Param("id")
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
return err
}
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
return err
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, credentialDtos)
return nil
}
// listUsersHandler godoc
@@ -119,26 +117,25 @@ func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserDto]
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) listUsersHandler(c *gin.Context) error {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
Data: usersDto,
Pagination: pagination,
})
return nil
}
// getUserHandler godoc
@@ -148,20 +145,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
// @Param id path string true "User ID"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id} [get]
func (uc *UserController) getUserHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) error {
user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, userDto)
return nil
}
// getCurrentUserHandler godoc
@@ -170,20 +166,19 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
// @Tags Users
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [get]
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
func (uc *UserController) getCurrentUserHandler(c *gin.Context) error {
user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, userDto)
return nil
}
// deleteUserHandler godoc
@@ -193,19 +188,18 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /api/users/{id} [delete]
func (uc *UserController) deleteUserHandler(c *gin.Context) {
func (uc *UserController) deleteUserHandler(c *gin.Context) error {
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
if err := uc.userService.DeleteUser(c.Request.Context(), dbConfig, c.Param("id"), false); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// deleteUserWebauthnCredentialHandler godoc
@@ -216,7 +210,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
// @Param credentialId path string true "Credential ID"
// @Success 204 "No Content"
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) error {
err := uc.webAuthnService.DeleteCredential(
c.Request.Context(),
c.Param("id"),
@@ -226,11 +220,11 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
c.GetString("userID"),
)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// createUserHandler godoc
@@ -240,32 +234,29 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
// @Param user body dto.UserCreateDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/users [post]
func (uc *UserController) createUserHandler(c *gin.Context) {
func (uc *UserController) createUserHandler(c *gin.Context) error {
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input dto.UserCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
user, err := uc.userService.CreateUser(c.Request.Context(), dbConfig, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, userDto)
return nil
}
// updateUserHandler godoc
@@ -276,8 +267,8 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
// @Param user body dto.UserCreateDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id} [put]
func (uc *UserController) updateUserHandler(c *gin.Context) {
uc.updateUser(c, false)
func (uc *UserController) updateUserHandler(c *gin.Context) error {
return uc.updateUser(c, false)
}
// updateCurrentUserHandler godoc
@@ -287,8 +278,8 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Param user body dto.UserCreateDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
uc.updateUser(c, true)
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) error {
return uc.updateUser(c, true)
}
// getUserProfilePictureHandler godoc
@@ -299,13 +290,12 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
// @Param id path string true "User ID"
// @Success 200 {file} binary "PNG image"
// @Router /api/users/{id}/profile-picture.png [get]
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) error {
userID := c.Param("id")
picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
return err
}
if picture != nil {
defer picture.Close()
@@ -314,6 +304,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
return nil
}
// updateUserProfilePictureHandler godoc
@@ -326,26 +317,24 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
// @Success 204 "No Content"
// @Router /api/users/{id}/profile-picture [put]
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) error {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
fileHeader, err := httpserver.FormFile(c, "file")
if err != nil {
_ = c.Error(err)
return
return err
}
file, err := fileHeader.Open()
if err != nil {
_ = c.Error(err)
return
return err
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updateCurrentUserProfilePictureHandler godoc
@@ -357,26 +346,24 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
// @Success 204 "No Content"
// @Router /api/users/me/profile-picture [put]
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) error {
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
fileHeader, err := httpserver.FormFile(c, "file")
if err != nil {
_ = c.Error(err)
return
return err
}
file, err := fileHeader.Open()
if err != nil {
_ = c.Error(err)
return
return err
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updateUserGroups godoc
@@ -387,40 +374,36 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id}/user-groups [put]
func (uc *UserController) updateUserGroups(c *gin.Context) {
func (uc *UserController) updateUserGroups(c *gin.Context) error {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds)
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, userDto)
return nil
}
// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) error {
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input dto.UserCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
var userID string
@@ -432,17 +415,16 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
user, err := uc.userService.UpdateUser(c.Request.Context(), dbConfig, userID, input, updateOwnUser, false)
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, userDto)
return nil
}
// resetUserProfilePictureHandler godoc
@@ -453,15 +435,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /api/users/{id}/profile-picture [delete]
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) error {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// resetCurrentUserProfilePictureHandler godoc
@@ -471,13 +453,13 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/profile-picture [delete]
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) error {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -25,13 +26,13 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
userGroupsGroup := group.Group("/user-groups")
userGroupsGroup.Use(authMiddleware.Add())
{
userGroupsGroup.GET("", ugc.list)
userGroupsGroup.GET("/:id", ugc.get)
userGroupsGroup.POST("", ugc.create)
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
userGroupsGroup.GET("", httpserver.Handle(ugc.list))
userGroupsGroup.GET("/:id", httpserver.Handle(ugc.get))
userGroupsGroup.POST("", httpserver.Handle(ugc.create))
userGroupsGroup.PUT("/:id", httpserver.Handle(ugc.update))
userGroupsGroup.DELETE("/:id", httpserver.Handle(ugc.delete))
userGroupsGroup.PUT("/:id/users", httpserver.Handle(ugc.updateUsers))
userGroupsGroup.PUT("/:id/allowed-oidc-clients", httpserver.Handle(ugc.updateAllowedOidcClients))
}
}
@@ -51,14 +52,13 @@ type UserGroupController struct {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
func (ugc *UserGroupController) list(c *gin.Context) error {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
// Map the user groups to DTOs
@@ -66,13 +66,11 @@ func (ugc *UserGroupController) list(c *gin.Context) {
for i, group := range groups {
var groupDto dto.UserGroupMinimalDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
return err
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
_ = c.Error(err)
return
return err
}
groupsDto[i] = groupDto
}
@@ -81,6 +79,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
Data: groupsDto,
Pagination: pagination,
})
return nil
}
// get godoc
@@ -92,20 +91,19 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
func (ugc *UserGroupController) get(c *gin.Context) error {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
return err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, groupDto)
return nil
}
// create godoc
@@ -117,26 +115,24 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDto "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
func (ugc *UserGroupController) create(c *gin.Context) error {
var input dto.UserGroupCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, groupDto)
return nil
}
// update godoc
@@ -149,32 +145,29 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
func (ugc *UserGroupController) update(c *gin.Context) error {
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input dto.UserGroupCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, groupDto)
return nil
}
// delete godoc
@@ -186,19 +179,18 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
func (ugc *UserGroupController) delete(c *gin.Context) error {
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// updateUsers godoc
@@ -211,26 +203,24 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
func (ugc *UserGroupController) updateUsers(c *gin.Context) error {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
if err != nil {
_ = c.Error(err)
return
return err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, groupDto)
return nil
}
// updateAllowedOidcClients godoc
@@ -243,24 +233,22 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) error {
var input dto.UserGroupUpdateAllowedOidcClientsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
return err
}
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, userGroupDto)
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -14,8 +15,8 @@ import (
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
group.GET("/version/latest", httpserver.Handle(vc.getLatestVersionHandler))
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(vc.getCurrentVersionHandler))
}
type VersionController struct {
@@ -28,11 +29,10 @@ type VersionController struct {
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) error {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
@@ -40,6 +40,7 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
return nil
}
// getCurrentVersionHandler godoc
@@ -48,9 +49,9 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
// @Produce json
// @Success 200 {object} map[string]string "Current version information"
// @Router /api/version/current [get]
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) error {
c.JSON(http.StatusOK, gin.H{
"currentVersion": common.Version,
})
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -21,8 +22,8 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
getCIMDURLAllowlist: getCIMDURLAllowlist,
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
group.GET("/.well-known/jwks.json", httpserver.Handle(wkc.jwksHandler))
group.GET("/.well-known/openid-configuration", httpserver.Handle(wkc.openIDConfigurationHandler))
}
type WellKnownController struct {
@@ -37,14 +38,14 @@ type WellKnownController struct {
// @Produce json
// @Success 200 {object} object "{ \"keys\": []interface{} }"
// @Router /.well-known/jwks.json [get]
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) error {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
_ = c.Error(err)
return
return err
}
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
return nil
}
// openIDConfigurationHandler godoc
@@ -53,13 +54,13 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
// @Tags Well Known
// @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) error {
oidcConfig, err := wkc.computeOIDCConfiguration()
if err != nil {
_ = c.Error(err)
return
return err
}
c.Data(http.StatusOK, "application/json; charset=utf-8", oidcConfig)
return nil
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -32,11 +33,10 @@ func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *
// @Produce json
// @Success 201 {object} requestCreateDto "Created device login request"
// @Router /api/device-login/requests [post]
func (h *handler) createRequest(c *gin.Context) {
func (h *handler) createRequest(c *gin.Context) error {
request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
return err
}
verificationURI := h.baseURL + "/device"
@@ -50,6 +50,7 @@ func (h *handler) createRequest(c *gin.Context) {
ExpiresAt: request.ExpiresAt,
Interval: PollingInterval,
})
return nil
}
// exchangeRequest godoc
@@ -61,11 +62,10 @@ func (h *handler) createRequest(c *gin.Context) {
// @Success 200 {object} dto.UserDto "Approved request exchanged for a user session"
// @Success 202 "Authorization pending"
// @Router /api/device-login/requests/{id}/exchange [post]
func (h *handler) exchangeRequest(c *gin.Context) {
func (h *handler) exchangeRequest(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
requestID := c.Param("id")
@@ -76,20 +76,20 @@ func (h *handler) exchangeRequest(c *gin.Context) {
if c.Request.Context().Err() != nil {
// Context canceled = the client stopped the request
// Nothing to do here
return
return c.Request.Context().Err()
}
_ = c.Error(err)
return
return err
}
if status == RequestStatusPending {
// Request is pending, so respond with a 202
c.Status(http.StatusAccepted)
return
return nil
}
maxAge := int(sessionDuration.Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
c.JSON(http.StatusOK, dto.UserDto(user))
return nil
}
// inspectRequest godoc
@@ -101,21 +101,20 @@ func (h *handler) exchangeRequest(c *gin.Context) {
// @Param request body verificationDto true "Device login code"
// @Success 200 {object} verificationInfoDto "Device login request details"
// @Router /api/device-login/verification [post]
func (h *handler) inspectRequest(c *gin.Context) {
func (h *handler) inspectRequest(c *gin.Context) error {
var input verificationDto
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
info, err := h.service.Inspect(c.Request.Context(), input.Code)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, verificationInfoDto(info))
return nil
}
// decideRequest godoc
@@ -126,20 +125,19 @@ func (h *handler) inspectRequest(c *gin.Context) {
// @Param decision body decisionDto true "Device login decision"
// @Success 204 "No Content"
// @Router /api/device-login/verification/decision [post]
func (h *handler) decideRequest(c *gin.Context) {
func (h *handler) decideRequest(c *gin.Context) error {
var input decisionDto
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
err = h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -72,8 +73,8 @@ func New(deps Dependencies) (*Module, error) {
// RegisterRoutes mounts the public exchange and authenticated verification endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, exchangeRateLimit, verificationRateLimit gin.HandlerFunc) {
apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest)
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, m.handler.exchangeRequest)
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest)
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest)
apiGroup.POST("/device-login/requests", createRateLimit, httpserver.Handle(m.handler.createRequest))
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, httpserver.Handle(m.handler.exchangeRequest))
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.inspectRequest))
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.decideRequest))
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/italypaleale/francis/actor"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -154,7 +154,7 @@ func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenti
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) {
if requestID == "" || deviceToken == "" || sessionDuration <= 0 {
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
}
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
@@ -212,9 +212,9 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
case RequestStatusPending:
// no-op
case RequestStatusDenied:
return dto.UserDto{}, "", result.Status, &common.DeviceLoginDeniedError{}
return dto.UserDto{}, "", result.Status, apperror.DeviceLoginDenied()
default:
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
}
select {
@@ -230,7 +230,7 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error {
if token == "" {
return &common.ReauthenticationRequiredError{}
return apperror.ReauthenticationRequired()
}
tx := s.db.WithContext(ctx).Begin()
@@ -241,14 +241,13 @@ func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userI
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID)
if err != nil {
var reauthenticationRequiredError *common.ReauthenticationRequiredError
if errors.As(err, &reauthenticationRequiredError) {
return &common.ReauthenticationRequiredError{}
if apperror.IsCode(err, apperror.CodeReauthenticationRequired) {
return apperror.ReauthenticationRequired()
}
return err
}
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
return &common.ReauthenticationRequiredError{}
return apperror.ReauthenticationRequired()
}
if err = tx.Commit().Error; err != nil {
@@ -262,11 +261,11 @@ func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.Us
err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return model.User{}, dto.UserDto{}, &common.DeviceLoginRequestInvalidOrExpiredError{}
return model.User{}, dto.UserDto{}, apperror.DeviceLoginRequestInvalidOrExpired()
case err != nil:
return model.User{}, dto.UserDto{}, err
case user.Disabled:
return model.User{}, dto.UserDto{}, &common.UserDisabledError{}
return model.User{}, dto.UserDto{}, apperror.UserDisabled()
}
var userDTO dto.UserDto
@@ -320,9 +319,9 @@ func actorResultError(code requestActorResultCode) error {
case requestActorResultCollision:
return errors.New("unexpected live device login actor collision")
case requestActorResultInvalid:
return &common.DeviceLoginRequestInvalidOrExpiredError{}
return apperror.DeviceLoginRequestInvalidOrExpired()
case requestActorResultDenied:
return &common.DeviceLoginDeniedError{}
return apperror.DeviceLoginDenied()
default:
return fmt.Errorf("unsupported device login actor result %q", code)
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -33,7 +33,7 @@ type fakeReauthenticationTokenConsumer struct {
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
if token != f.expectedValue {
return time.Time{}, &common.ReauthenticationRequiredError{}
return time.Time{}, apperror.ReauthenticationRequired()
}
if !f.createdAt.IsZero() {
return f.createdAt, nil
@@ -200,8 +200,7 @@ func TestPendingAndDeniedRequests(t *testing.T) {
require.NoError(t, err)
user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
var deniedError *common.DeviceLoginDeniedError
require.ErrorAs(t, err, &deniedError)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
require.Empty(t, user.ID)
require.Empty(t, accessToken)
@@ -228,8 +227,7 @@ func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
select {
case outcome := <-result:
var deniedError *common.DeviceLoginDeniedError
require.ErrorAs(t, outcome.err, &deniedError)
require.True(t, apperror.IsCode(outcome.err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, outcome.status)
case <-time.After(2 * time.Second):
t.Fatal("exchange did not observe the actor decision")
@@ -282,8 +280,7 @@ func TestRejectsDisabledUserAtExchange(t *testing.T) {
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
_, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
var disabledError *common.UserDisabledError
require.ErrorAs(t, err, &disabledError)
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
require.Empty(t, accessToken)
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
}
@@ -322,13 +319,12 @@ func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
require.NoError(t, err)
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
var reauthenticationError *common.ReauthenticationRequiredError
require.ErrorAs(t, err, &reauthenticationError)
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
fixture.reauth.expectedValue = "stale-proof"
fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute)
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
require.ErrorAs(t, err, &reauthenticationError)
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status)
}
@@ -374,8 +370,7 @@ func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
successfulTokens = append(successfulTokens, result.token)
continue
}
var invalidRequestError *common.DeviceLoginRequestInvalidOrExpiredError
require.ErrorAs(t, result.err, &invalidRequestError)
require.True(t, apperror.IsCode(result.err, apperror.CodeDeviceLoginExpired))
invalidExchanges++
}
require.Equal(t, []string{"device-login-access-token"}, successfulTokens)
@@ -422,8 +417,7 @@ func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
_, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
var deniedError *common.DeviceLoginDeniedError
require.ErrorAs(t, err, &deniedError)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
}
@@ -527,8 +521,7 @@ func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorI
func assertInvalidRequestError(t *testing.T, err error) {
t.Helper()
var invalidError *common.DeviceLoginRequestInvalidOrExpiredError
require.ErrorAs(t, err, &invalidError)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginExpired))
}
func persistentTestDependencies(db *gorm.DB) Dependencies {

View File

@@ -3,72 +3,83 @@ package dto
import (
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"golang.org/x/text/unicode/norm"
)
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag
func Normalize(obj any) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Pointer || v.IsNil() {
normalizeValue(reflect.ValueOf(obj))
}
func normalizeValue(value reflect.Value) {
if !value.IsValid() {
return
}
v = v.Elem()
// Handle case where obj is a slice of models
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface())
}
// Unwrap interfaces and pointers so nested DTOs share the same traversal
kind := value.Kind()
if kind == reflect.Interface || kind == reflect.Pointer {
if !value.IsNil() {
normalizeValue(value.Elem())
}
return
}
if v.Kind() != reflect.Struct {
// Walk collections because request DTOs may contain nested slices or arrays
if kind == reflect.Slice || kind == reflect.Array {
for i := range value.Len() {
normalizeValue(value.Index(i))
}
return
}
// Iterate through all fields looking for those with the "unorm" tag
t := v.Type()
loop:
for i := range t.NumField() {
field := t.Field(i)
// Ignore scalar values because normalization is opt-in through struct field tags
if kind != reflect.Struct {
return
}
unormTag := field.Tag.Get("unorm")
if unormTag == "" {
// Normalize tagged fields directly and recursively inspect untagged nested values
valueType := value.Type()
for i := range value.NumField() {
field := value.Field(i)
form, tagged := normalizationForm(valueType.Field(i).Tag.Get("unorm"))
if tagged {
normalizeString(field, form)
continue
}
fv := v.Field(i)
if !fv.CanSet() || fv.Kind() != reflect.String {
continue
}
var form norm.Form
switch unormTag {
case "nfc":
form = norm.NFC
case "nfkc":
form = norm.NFKC
case "nfd":
form = norm.NFD
case "nfkd":
form = norm.NFKD
default:
continue loop
}
val := fv.String()
val = form.String(val)
fv.SetString(val)
normalizeValue(field)
}
}
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
func normalizationForm(tag string) (norm.Form, bool) {
switch tag {
case "nfc":
return norm.NFC, true
case "nfkc":
return norm.NFKC, true
case "nfd":
return norm.NFD, true
case "nfkd":
return norm.NFKD, true
default:
return 0, false
}
}
func normalizeString(value reflect.Value, form norm.Form) {
// Dereference optional string fields while leaving nil values unchanged
if value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
value = value.Elem()
}
// Ignore incompatible or read-only fields because reflection cannot safely update them
if value.Kind() != reflect.String || !value.CanSet() {
return
}
value.SetString(form.String(value.String()))
}

View File

@@ -43,7 +43,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
wantErr: "Field validation for 'username' failed on the 'required' tag",
},
{
name: "missing first name",
@@ -73,7 +73,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'username' tag",
wantErr: "Field validation for 'username' failed on the 'username' tag",
},
{
name: "invalid email",
@@ -84,7 +84,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Email' failed on the 'email' tag",
wantErr: "Field validation for 'email' failed on the 'email' tag",
},
{
name: "first name too short",
@@ -106,7 +106,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
wantErr: "Field validation for 'lastName' failed on the 'max' tag",
},
}

View File

@@ -2,6 +2,7 @@ package dto
import (
"net/url"
"reflect"
"regexp"
"strings"
"time"
@@ -24,6 +25,15 @@ var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() {
engine := binding.Validator.Engine().(*validator.Validate)
// Use JSON tags to keep client-visible validation field names stable
engine.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "" || name == "-" {
return field.Name
}
return name
})
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour

View File

@@ -16,6 +16,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/resources"
@@ -69,12 +70,15 @@ func (m *Module) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfi
WithContext(ctx).
First(&user, "id = ?", recipientUserID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.UserNotFound()
}
if err != nil {
return err
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
return apperror.UserEmailNotSet()
}
return send(ctx, m, dbConfig, address{

View File

@@ -16,7 +16,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -134,8 +134,16 @@ func TestSendTestEmailRequiresUserEmail(t *testing.T) {
require.NoError(t, err)
err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, user.ID)
var emailNotSetError *common.UserEmailNotSetError
require.ErrorAs(t, err, &emailNotSetError)
require.True(t, apperror.IsCode(err, apperror.CodeUserEmailNotSet))
}
func TestSendTestEmailRejectsMissingUser(t *testing.T) {
module, err := New(testutils.NewDatabaseForTest(t))
require.NoError(t, err)
err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, "missing-user")
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
}
func TestSMTPConnStringPreservesConfiguration(t *testing.T) {

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
)
type handler struct {
@@ -25,20 +26,19 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/send-email-verification [post]
func (h *handler) send(c *gin.Context) {
func (h *handler) send(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
err = h.service.Send(c.Request.Context(), dbConfig, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// verify godoc
@@ -48,18 +48,17 @@ func (h *handler) send(c *gin.Context) {
// @Param body body dto.EmailVerificationDto true "Email verification token"
// @Success 204 "No Content"
// @Router /api/users/me/verify-email [post]
func (h *handler) verify(c *gin.Context) {
func (h *handler) verify(c *gin.Context) error {
var input dto.EmailVerificationDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
err := h.service.Verify(c.Request.Context(), c.GetString("userID"), input.Token)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
)
type AppConfigResolver interface {
@@ -45,6 +46,6 @@ func New(deps Dependencies) (*Module, error) {
// RegisterRoutes mounts the email verification endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, sendRateLimit, verifyRateLimit gin.HandlerFunc) {
apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, m.handler.send)
apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, m.handler.verify)
apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, httpserver.Handle(m.handler.send))
apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, httpserver.Handle(m.handler.verify))
}

View File

@@ -10,7 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -50,7 +50,7 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel,
return err
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
return apperror.UserEmailNotSet()
}
token, err := utils.GenerateRandomAlphanumericString(32)
@@ -104,7 +104,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
return fmt.Errorf("error decoding email verification actor response: %w", err)
}
if result.Status != consumeOK {
return &common.InvalidEmailVerificationTokenError{}
return apperror.InvalidEmailVerificationToken()
}
// Update the user's email_verified field in the database
@@ -123,7 +123,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
return update.Error
}
if update.RowsAffected != 1 {
return &common.InvalidEmailVerificationTokenError{}
return apperror.InvalidEmailVerificationToken()
}
return nil

View File

@@ -13,7 +13,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -142,8 +142,7 @@ func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
}).Error)
err := service.Verify(t.Context(), user.ID, token)
var invalidTokenError *common.InvalidEmailVerificationTokenError
require.ErrorAs(t, err, &invalidTokenError)
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
var updated model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
@@ -162,8 +161,7 @@ func TestVerifyDoesNotConsumeStateForWrongToken(t *testing.T) {
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
err := service.Verify(t.Context(), user.ID, "wrong-verification-code")
var invalidTokenError *common.InvalidEmailVerificationTokenError
require.ErrorAs(t, err, &invalidTokenError)
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
var state State
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
@@ -187,8 +185,7 @@ func TestVerifyRejectsExpiredToken(t *testing.T) {
}, time.Second, time.Millisecond)
err := service.Verify(t.Context(), user.ID, token)
var invalidTokenError *common.InvalidEmailVerificationTokenError
require.ErrorAs(t, err, &invalidTokenError)
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
var updated model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)

View File

@@ -0,0 +1,69 @@
package httpserver
import (
"errors"
"io"
"mime/multipart"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// BindJSON binds and normalizes a JSON request while distinguishing invalid input from internal failures
func BindJSON(c *gin.Context, value any) error {
err := classifyBindingError(c.ShouldBindJSON(value))
if err != nil {
return err
}
dto.Normalize(value)
return nil
}
// BindOptionalJSON accepts an empty body while normalizing valid input and classifying malformed JSON as invalid input
func BindOptionalJSON(c *gin.Context, value any) error {
err := c.ShouldBindJSON(value)
if errors.Is(err, io.EOF) {
return nil
}
if err = classifyBindingError(err); err != nil {
return err
}
dto.Normalize(value)
return nil
}
// FormFile returns an uploaded file while classifying a missing field as request validation
func FormFile(c *gin.Context, field string) (*multipart.FileHeader, error) {
file, err := c.FormFile(field)
if errors.Is(err, http.ErrMissingFile) {
return nil, apperror.MissingField(field)
}
if err != nil {
return nil, apperror.InvalidRequestBody(err)
}
return file, nil
}
func classifyBindingError(err error) error {
if err == nil {
return nil
}
if _, ok := errors.AsType[validator.ValidationErrors](err); ok {
return err
}
if _, ok := errors.AsType[binding.SliceValidationError](err); ok {
return err
}
return apperror.InvalidRequestBody(err)
}

View File

@@ -0,0 +1,85 @@
package httpserver
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/stretchr/testify/require"
"golang.org/x/text/unicode/norm"
)
func TestBindJSONClassifiesMalformedBody(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"name":`))
c.Request.Header.Set("Content-Type", "application/json")
var input struct {
Name string `json:"name"`
}
err := BindJSON(c, &input)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidRequestBody))
require.Contains(t, err.Error(), "unexpected EOF")
var appErr *apperror.Error
require.ErrorAs(t, err, &appErr)
require.NotContains(t, appErr.ClientMessage(), "unexpected end")
}
func TestBindJSONNormalizesTaggedFieldsRecursively(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/",
strings.NewReader(`{"name":"Cafe\u0301","email":"user@cafe\u0301.example","items":[{"label":"Re\u0301sume\u0301"}]}`),
)
c.Request.Header.Set("Content-Type", "application/json")
type embeddedInput struct {
Name string `json:"name" unorm:"nfc"`
}
type itemInput struct {
Label string `json:"label" unorm:"nfc"`
}
var input struct {
embeddedInput
Email *string `json:"email" unorm:"nfc"`
Items []itemInput `json:"items"`
}
err := BindJSON(c, &input)
require.NoError(t, err)
require.Equal(t, norm.NFC.String("Café"), input.Name)
require.NotNil(t, input.Email)
require.Equal(t, norm.NFC.String("user@café.example"), *input.Email)
require.Equal(t, norm.NFC.String("Résumé"), input.Items[0].Label)
}
func TestFormFileClassifiesMissingField(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.Close())
c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
_, err := FormFile(c, "file")
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
var appErr *apperror.Error
require.ErrorAs(t, err, &appErr)
require.Equal(t, []apperror.FieldError{{
Field: "file",
Code: "required",
Message: "is required",
}}, appErr.Fields())
}

View File

@@ -0,0 +1,46 @@
package httpserver
import (
"context"
"errors"
"fmt"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// HandlerFunc is an application HTTP handler that returns failures to the shared error middleware
type HandlerFunc func(*gin.Context) error
// Handle adapts an error-returning application handler to Gin
func Handle(handler HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
recovered := recover()
if recovered == nil {
return
}
// Preserve net/http's intentional request-abort behavior without logging it as an application panic
if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
panic(recovered)
}
// Keep recovered errors out of the unwrap chain so every panic is reported as an internal failure
err := fmt.Errorf("panic in HTTP handler (%T): %v\n%s", recovered, recovered, debug.Stack())
_ = c.Error(err)
c.Abort()
}()
if err := handler(c); err != nil {
if errors.Is(err, context.Canceled) {
c.Abort()
return
}
_ = c.Error(err)
c.Abort()
}
}
}

View File

@@ -0,0 +1,64 @@
package httpserver
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestHandleAttachesAndAbortsOnError(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
expected := errors.New("request failed")
Handle(func(*gin.Context) error {
return expected
})(c)
require.True(t, c.IsAborted())
require.Len(t, c.Errors, 1)
require.ErrorIs(t, c.Errors[0], expected)
}
func TestHandleIgnoresCanceledRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
Handle(func(*gin.Context) error {
return context.Canceled
})(c)
require.True(t, c.IsAborted())
require.Empty(t, c.Errors)
}
func TestHandleRecoversPanicsWithStack(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
Handle(func(*gin.Context) error {
panic("private panic details")
})(c)
require.True(t, c.IsAborted())
require.Len(t, c.Errors, 1)
require.ErrorContains(t, c.Errors[0], "panic in HTTP handler (string): private panic details")
require.ErrorContains(t, c.Errors[0], "TestHandleRecoversPanicsWithStack")
}
func TestHandlePreservesAbortHandlerPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
require.PanicsWithValue(t, http.ErrAbortHandler, func() {
Handle(func(*gin.Context) error {
panic(http.ErrAbortHandler)
})(c)
})
require.Empty(t, c.Errors)
}

View File

@@ -3,7 +3,7 @@ package middleware
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -39,15 +39,15 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI
user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {
return "", false, &common.NotSignedInError{}
return "", false, apperror.NotSignedIn()
}
if user.Disabled {
return "", false, &common.UserDisabledError{}
return "", false, apperror.UserDisabled()
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{}
return "", false, apperror.MissingPermission()
}
return user.ID, user.IsAdmin, nil

View File

@@ -1,11 +1,9 @@
package middleware
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -88,8 +86,8 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
return
}
// If JWT auth failed and the error is not a NotSignedInError, abort the request
if !errors.Is(err, &common.NotSignedInError{}) {
// If JWT auth failed for a reason other than missing credentials, abort the request
if !apperror.IsCode(err, apperror.CodeNotSignedIn) {
c.Abort()
_ = c.Error(err)
return
@@ -103,7 +101,7 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
c.Abort()
if c.GetHeader("X-API-Key") != "" {
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
_ = c.Error(apperror.APIKeyAuthNotAllowed())
return
}
_ = c.Error(err)

View File

@@ -1,125 +1,278 @@
package middleware
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"strings"
"time"
"unicode"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/pocket-id/pocket-id/backend/internal/common"
"gorm.io/gorm"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"go.opentelemetry.io/otel/trace"
)
const requestIDHeader = "X-Request-ID"
type requestIDContextKey struct{}
type requestErrorCodeContextKey struct{}
// RequestID returns the identifier assigned to the current request
func RequestID(c *gin.Context) string {
if c == nil {
return ""
}
requestID, exists := c.Get(requestIDContextKey{})
if !exists {
return ""
}
value, ok := requestID.(string)
if !ok {
return ""
}
return value
}
// RequestErrorCode returns the stable code of the error handled for the current request
func RequestErrorCode(c *gin.Context) apperror.Code {
if c == nil {
return ""
}
code, exists := c.Get(requestErrorCodeContextKey{})
if !exists {
return ""
}
value, ok := code.(apperror.Code)
if !ok {
return ""
}
return value
}
type ErrorHandlerMiddleware struct{}
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
return &ErrorHandlerMiddleware{}
}
type classifiedError struct {
code apperror.Code
status int
message string
details map[string]string
fields []apperror.FieldError
retryAfter time.Duration
}
// Add records a request ID before executing the request and serializes the first returned error afterward
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := uuid.NewString()
c.Set(requestIDContextKey{}, requestID)
c.Header(requestIDHeader, requestID)
c.Next()
for _, err := range c.Errors {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
errorResponse(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
// Check for slice validation errors
svErr, ok := errors.AsType[binding.SliceValidationError](err)
if ok {
if errors.As(svErr[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
// AppError with description
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
if ok {
errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description())
return
}
// AppError (without description)
appErr, ok := errors.AsType[common.AppError](err)
if ok {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, errorResponseBody{
Error: "Something went wrong",
})
if len(c.Errors) == 0 {
return
}
err := c.Errors[0].Err
classified := classifyError(err)
c.Set(requestErrorCodeContextKey{}, classified.code)
logRequestError(c, err, classified, requestID)
if c.Writer.Written() {
return
}
if classified.retryAfter > 0 {
c.Header("Retry-After", formatRetryAfter(classified.retryAfter))
}
writeErrorResponse(c, classified, requestID)
}
}
type errorResponseBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
Error string `json:"error"`
Code apperror.Code `json:"code"`
Details map[string]any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
func errorResponse(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
})
}
func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
ErrorDescription: description,
})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "resource_uri":
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
func classifyError(err error) classifiedError {
var structuredErr *apperror.Error
if errors.As(err, &structuredErr) && structuredErr != nil {
return classifiedError{
code: structuredErr.Code(),
status: normalizeStatus(structuredErr.HTTPStatus()),
message: capitalizeFirst(structuredErr.ClientMessage()),
details: structuredErr.Details(),
fields: structuredErr.Fields(),
retryAfter: structuredErr.RetryAfter(),
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
if errors.Is(err, context.DeadlineExceeded) {
return classifiedError{
code: apperror.CodeRequestTimeout,
status: http.StatusGatewayTimeout,
message: "Request timed out",
}
}
return combinedErrors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) && len(validationErrors) > 0 {
return classifiedValidationError(validationErrors)
}
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 {
if errors.As(sliceValidationErrors[0], &validationErrors) {
return classifiedValidationError(validationErrors)
}
}
return classifiedError{
code: apperror.CodeInternal,
status: http.StatusInternalServerError,
message: "Something went wrong",
}
}
func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError {
fields := make([]apperror.FieldError, 0, len(validationErrors))
messages := make([]string, 0, len(validationErrors))
for _, validationError := range validationErrors {
fieldName := validationError.Field()
code, message := validationFieldError(validationError)
fields = append(fields, apperror.FieldError{
Field: fieldName,
Code: code,
Message: message,
})
messages = append(messages, fieldName+" "+message)
}
return classifiedError{
code: apperror.CodeValidationFailed,
status: http.StatusBadRequest,
message: capitalizeFirst(strings.Join(messages, ", ")),
fields: fields,
}
}
func validationFieldError(validationError validator.FieldError) (string, string) {
switch validationError.Tag() {
case "required":
return "required", "is required"
case "email":
return "invalid_format", "must be a valid email address"
case "username":
return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character"
case "url":
return "invalid_format", "must be a valid URL"
case "resource_uri":
return "invalid_format", "must be an absolute URI without whitespace or a fragment"
case "min":
return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param())
case "max":
return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param())
default:
return validationError.Tag(), "is invalid"
}
}
func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) {
details := make(map[string]any, len(classified.details)+1)
for key, value := range classified.details {
details[key] = value
}
if len(classified.fields) > 0 {
details["fields"] = classified.fields
}
if len(details) == 0 {
details = nil
}
response := errorResponseBody{
Error: classified.message,
Code: classified.code,
Details: details,
RequestID: requestID,
}
c.JSON(classified.status, response)
}
func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) {
if classified.status < http.StatusInternalServerError {
return
}
attrs := []any{
slog.String("error_code", string(classified.code)),
slog.String("error_type", errorTypeName(err)),
slog.Int("http_status", classified.status),
slog.String("request_id", requestID),
slog.String("http_method", c.Request.Method),
slog.String("http_path", c.Request.URL.Path),
slog.Any("error", err),
}
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String()))
}
slog.ErrorContext(c.Request.Context(), "Request failed", attrs...)
}
func errorTypeName(err error) string {
if err == nil {
return "<nil>"
}
return reflect.TypeOf(err).String()
}
func normalizeStatus(status int) int {
if status < http.StatusBadRequest || status > 599 {
return http.StatusInternalServerError
}
return status
}
func formatRetryAfter(retryAfter time.Duration) string {
seconds := int(retryAfter / time.Second)
if retryAfter%time.Second != 0 {
seconds++
}
if seconds < 1 {
seconds = 1
}
return fmt.Sprintf("%d", seconds)
}
func capitalizeFirst(message string) string {
runes := []rune(message)
if len(runes) == 0 {
return message
}
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}

View File

@@ -0,0 +1,180 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestErrorHandlerMiddlewareStructuredError(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
var handlerRequestID string
router.GET("/users/1", httpserver.Handle(func(c *gin.Context) error {
handlerRequestID = RequestID(c)
cause := errors.New("database connection details")
return apperror.Wrap(cause, apperror.CodeUserNotFound, http.StatusNotFound, "User not found")
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/1", nil)
router.ServeHTTP(recorder, request)
var body struct {
Error string `json:"error"`
Code apperror.Code `json:"code"`
Details map[string]string `json:"details"`
RequestID string `json:"request_id"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusNotFound, recorder.Code)
require.Equal(t, "User not found", body.Error)
require.Equal(t, apperror.CodeUserNotFound, body.Code)
require.Empty(t, body.Details)
require.NotEmpty(t, body.RequestID)
require.Equal(t, body.RequestID, handlerRequestID)
require.Equal(t, body.RequestID, recorder.Header().Get(requestIDHeader))
require.NotContains(t, recorder.Body.String(), "database connection details")
}
func TestErrorHandlerMiddlewareHidesUnexpectedError(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/failure", func(c *gin.Context) {
_ = c.Error(errors.New("private database details"))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/failure", nil)
router.ServeHTTP(recorder, request)
var body map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.Equal(t, "Something went wrong", body["error"])
require.Equal(t, string(apperror.CodeInternal), body["code"])
require.NotContains(t, recorder.Body.String(), "private database details")
require.NotEmpty(t, body["request_id"])
}
func TestErrorHandlerMiddlewareHidesRecoveredPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/panic", httpserver.Handle(func(*gin.Context) error {
panic("private panic details")
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", nil)
router.ServeHTTP(recorder, request)
var body map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.Equal(t, "Something went wrong", body["error"])
require.Equal(t, string(apperror.CodeInternal), body["code"])
require.NotContains(t, recorder.Body.String(), "private panic details")
require.NotEmpty(t, body["request_id"])
}
func TestErrorHandlerMiddlewareIncludesSafeDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/users", func(c *gin.Context) {
_ = c.Error(apperror.AlreadyInUse("email"))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, request)
var body struct {
Details map[string]string `json:"details"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, map[string]string{"property": "email"}, body.Details)
}
func TestErrorHandlerMiddlewareSetsRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/limited", func(c *gin.Context) {
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(1500 * time.Millisecond))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/limited", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
require.Equal(t, "2", recorder.Header().Get("Retry-After"))
}
func TestValidationResponseUsesJSONFieldNames(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.POST("/users", httpserver.Handle(func(c *gin.Context) error {
var input dto.UserCreateDto
return httpserver.BindJSON(c, &input)
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/users", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
var body struct {
Details struct {
Fields []apperror.FieldError `json:"fields"`
} `json:"details"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Contains(t, body.Details.Fields, apperror.FieldError{
Field: "username",
Code: "required",
Message: "is required",
})
}
func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) {
classified := classifyError(gorm.ErrRecordNotFound)
require.Equal(t, apperror.CodeInternal, classified.code)
require.Equal(t, http.StatusInternalServerError, classified.status)
}
func TestClassifyDeadlineAsRequestTimeout(t *testing.T) {
classified := classifyError(context.DeadlineExceeded)
require.Equal(t, apperror.CodeRequestTimeout, classified.code)
require.Equal(t, http.StatusGatewayTimeout, classified.status)
require.Equal(t, "Request timed out", classified.message)
}

View File

@@ -1,11 +1,13 @@
package middleware
import (
"errors"
"fmt"
"mime/multipart"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
)
type FileSizeLimitMiddleware struct{}
@@ -18,7 +20,13 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
// Classify only size-limit failures as file_too_large so malformed multipart bodies remain invalid requests
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) || errors.Is(err, multipart.ErrMessageTooLarge) {
err = apperror.FileTooLarge(formatFileSize(maxSize))
} else {
err = apperror.InvalidRequestBody(err)
}
_ = c.Error(err)
c.Abort()
return

View File

@@ -0,0 +1,66 @@
package middleware
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/stretchr/testify/require"
)
func TestFileSizeLimitMiddlewareClassifiesMultipartErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("oversized body", func(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "large.png")
require.NoError(t, err)
_, err = part.Write(bytes.Repeat([]byte("x"), 128))
require.NoError(t, err)
require.NoError(t, writer.Close())
recorder := serveMultipartRequest(t, body.Bytes(), writer.FormDataContentType(), 64)
require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
require.Equal(t, apperror.CodeFileTooLarge, responseCode(t, recorder))
})
t.Run("malformed body", func(t *testing.T) {
recorder := serveMultipartRequest(t, []byte("not multipart"), "multipart/form-data; boundary=missing", 1024)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, apperror.CodeInvalidRequestBody, responseCode(t, recorder))
})
}
func serveMultipartRequest(t *testing.T, body []byte, contentType string, maxSize int64) *httptest.ResponseRecorder {
t.Helper()
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.POST("/", NewFileSizeLimitMiddleware().Add(maxSize), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", bytes.NewReader(body))
request.Header.Set("Content-Type", contentType)
router.ServeHTTP(recorder, request)
return recorder
}
func responseCode(t *testing.T, recorder *httptest.ResponseRecorder) apperror.Code {
t.Helper()
var body struct {
Code apperror.Code `json:"code"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
return body.Code
}

View File

@@ -5,7 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -44,37 +44,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
authenticationTime, _ = token.IssuedAt()
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return "", false, "", time.Time{}, &common.TokenInvalidError{}
_ = c.Error(apperror.TokenInvalid())
return "", false, "", time.Time{}, apperror.TokenInvalid()
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
if user.Disabled {
return "", false, "", time.Time{}, &common.UserDisabledError{}
return "", false, "", time.Time{}, apperror.UserDisabled()
}
if adminRequired && !user.IsAdmin {
return "", false, "", time.Time{}, &common.MissingPermissionError{}
return "", false, "", time.Time{}, apperror.MissingPermission()
}
return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil

View File

@@ -3,16 +3,15 @@ package middleware
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -82,11 +81,12 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
}
}
// A missing service means the policy was never registered on the actor host, which is a development-time errror
// A missing service means the policy was never registered on the actor host, which is a development-time error
svc := m.services[policy]
if svc == nil {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusInternalServerError)
_ = c.Error(apperror.Internal(fmt.Errorf("rate limiter service is not configured for policy %q", policy)))
c.Abort()
}
}
@@ -113,11 +113,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
}
if !allowed {
// Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
}
_ = c.Error(&common.TooManyRequestsError{})
// Advertise when the caller may retry, mapping the limiter's delay onto a structured application error
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(retryAfter))
c.Abort()
return
}

View File

@@ -144,6 +144,9 @@ func TestRateLimitMiddleware(t *testing.T) {
t.Run("fails with 500 when the policy is not registered", func(t *testing.T) {
// An unknown policy has no bound service, which is a configuration error surfaced as a 500
r := newRateLimitRouter(t, services, "does-not-exist")
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
w := doRateLimitRequest(t.Context(), r, "203.0.113.6")
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), `"code":"internal_error"`)
assert.NotContains(t, w.Body.String(), "rate limiter service is not configured")
})
}

View File

@@ -5,12 +5,13 @@ import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -124,8 +125,8 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
typedAuthenticationTime, _ := authenticationTime.(time.Time)
var request completeInteractionRequest
if err := c.ShouldBindJSON(&request); err != nil {
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
if err := httpserver.BindJSON(c, &request); err != nil {
_ = c.Error(err)
return
}
@@ -140,6 +141,12 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
// Keep authorization policy denials in Pocket ID so users can see why access was refused
if errors.Is(err, fosite.ErrAccessDenied) {
h.redirectToInteractionError(c, err)
return
}
if ar.IsRedirectURIValid() {
// Send the error to the client
// fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path
@@ -148,8 +155,10 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
return
}
// If no redirect URI is available, we can't send the error to the client,
// so we redirect to a generic error page instead.
h.redirectToInteractionError(c, err)
}
func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) {
errorMessage := "An unknown error occurred during the authorization request."
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if err.HintField != "" {
@@ -159,7 +168,9 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
}
}
c.Redirect(http.StatusFound, "/interaction/error?error="+errorMessage)
query := url.Values{}
query.Set("error", errorMessage)
c.Redirect(http.StatusFound, "/interaction/error?"+query.Encode())
}
func requestMetaFromGin(c *gin.Context) requestMeta {

View File

@@ -12,7 +12,7 @@ import (
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -119,7 +119,7 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
return authorizationResult{}, &common.OidcPARRequiredError{}
return authorizationResult{}, apperror.OidcPARRequired()
}
resource, err := input.requester.GetResource()
@@ -209,6 +209,9 @@ func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req a
Preload("UserGroups").
First(&user, "id = ?", req.userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return authorizationResult{}, apperror.UserNotFound()
}
if err != nil {
return authorizationResult{}, err
}
@@ -510,6 +513,9 @@ func (s *authorizationService) switchInteractionSessionUser(ctx context.Context,
func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return interactionSessionForUser{}, apperror.OidcInteractionNotFound()
}
if err != nil {
return interactionSessionForUser{}, err
}
@@ -578,6 +584,9 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte
err := withTx(ctx, s.db, func(ctx context.Context) error {
var err error
interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.OidcInteractionNotFound()
}
if err != nil {
return err
}
@@ -599,7 +608,7 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte
}
if requiredSteps[0] != step {
return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
return apperror.ValidationMessage("expected interaction step " + string(requiredSteps[0]) + " but got " + string(step))
}
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
@@ -646,7 +655,7 @@ func (s *authorizationService) applyInteractionStep(ctx context.Context, interac
case interactionStepConsent:
return s.completeConsentStep(ctx, interactionSession, userID, meta)
default:
return &common.ValidationError{Message: "unknown interaction step " + string(step)}
return apperror.ValidationMessage("unknown interaction step " + string(step))
}
}
@@ -655,7 +664,7 @@ func (s *authorizationService) completeReauthenticationStep(ctx context.Context,
return err
}
if reauthenticationToken == "" {
return &common.ValidationError{Message: "reauthentication token is required"}
return apperror.MissingField("reauthenticationToken")
}
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
if err != nil {

View File

@@ -9,7 +9,7 @@ import (
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -317,6 +317,17 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
_, err := service.getInteractionSession(t.Context(), "missing-interaction")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
_, err = service.completeInteractionStep(t.Context(), "missing-interaction", "user", interactionStepConsent, "", time.Now().UTC(), requestMeta{})
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
@@ -735,8 +746,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
// Without a pushed authorization request the client must be rejected
_, err := authorize("", false)
var parRequiredError *common.OidcPARRequiredError
require.ErrorAs(t, err, &parRequiredError)
require.True(t, apperror.IsCode(err, apperror.CodeOidcPARRequired))
// Re-entry after a completed interaction carries no request_uri, but the bound
// interaction session proves the original request was PAR-validated

View File

@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -45,7 +45,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
_ = c.Error(apperror.MissingField("code"))
return
}
@@ -73,7 +73,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
_ = c.Error(apperror.MissingField("code"))
return
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/rfc8628"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -93,12 +93,12 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
// logged-in user from rebinding a pending device authorization to themselves before the device
// polls for its token.
if request.GetUserCodeState() != fosite.UserCodeUnused {
return &common.OidcInvalidDeviceCodeError{}
return apperror.OidcInvalidDeviceCode()
}
client := request.GetClient().(Client)
var user model.User
if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
user, err := s.loadDeviceAuthorizationUser(ctx, userID)
if err != nil {
return err
}
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
@@ -118,7 +118,7 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
return withTx(ctx, s.db, func(ctx context.Context) error {
if client.RequiresReauthentication {
if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil {
return &common.ReauthenticationRequiredError{}
return apperror.ReauthenticationRequired()
}
reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
@@ -164,6 +164,25 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
})
}
func (s *deviceService) loadDeviceAuthorizationUser(ctx context.Context, userID string) (model.User, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var user model.User
err := tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, apperror.UserNotFound()
}
return user, err
}
func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) {
request, _, err := s.deviceRequestFromUserCode(ctx, userCode)
if err != nil {
@@ -232,7 +251,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode
request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature)
if errors.Is(err, fosite.ErrNotFound) {
return nil, "", &common.OidcInvalidDeviceCodeError{}
return nil, "", apperror.OidcInvalidDeviceCode()
}
if err != nil {
return nil, "", err
@@ -240,7 +259,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode
if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil {
if errors.Is(err, fosite.ErrDeviceExpiredToken) {
return nil, "", &common.OidcDeviceCodeExpiredError{}
return nil, "", apperror.OidcDeviceCodeExpired()
}
return nil, "", err
}

View File

@@ -13,13 +13,16 @@ import (
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// #nosec G101
const testReauthenticationToken = "valid-reauth-token"
type fakeReauthenticationConsumer struct {
token string
userID string
@@ -30,7 +33,7 @@ type fakeReauthenticationConsumer struct {
func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) {
f.calls++
if token != f.token || userID != f.userID {
return time.Time{}, &common.ReauthenticationRequiredError{}
return time.Time{}, apperror.ReauthenticationRequired()
}
return f.reauthenticatedAt, nil
@@ -41,15 +44,15 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t
userID = "test-user"
clientID = "test-client"
)
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
token: "valid-reauth-token",
reauth := &fakeReauthenticationConsumer{
token: testReauthenticationToken,
userID: userID,
reauthenticatedAt: time.Now().UTC().Truncate(time.Second),
}
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{})
require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
require.Zero(t, reauth.calls)
info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID)
@@ -75,8 +78,8 @@ func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.
clientID = "test-client"
)
reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second)
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
token: "valid-reauth-token",
reauth := &fakeReauthenticationConsumer{
token: testReauthenticationToken,
userID: userID,
reauthenticatedAt: reauthenticatedAt,
}
@@ -153,7 +156,8 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe
store := NewStore(db, apiAccess)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -34,35 +35,35 @@ func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL
// client's post-logout callback URL (empty if none is configured).
func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
token, err := s.verifyIDTokenHint(input.IdTokenHint)
if err != nil {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
clientIDs, ok := token.Audience()
if !ok || len(clientIDs) == 0 {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
clientID := clientIDs[0]
if input.ClientId != "" && clientID != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
return "", apperror.OidcClientIDNotMatching()
}
subject, ok := token.Subject()
if !ok || subject == "" {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
if userID != "" && subject != userID {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
userID = subject
idTokenJTI, ok := token.JwtID()
if !ok {
return "", &common.TokenInvalidError{}
return "", apperror.TokenInvalid()
}
var callbackURL string
@@ -74,7 +75,7 @@ func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogout
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.OidcMissingAuthorizationError{}
return apperror.OidcMissingAuthorization()
}
return err
}
@@ -117,7 +118,7 @@ func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, er
// key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout.
var tokenType string
if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType {
return nil, &common.TokenInvalidError{}
return nil, apperror.TokenInvalid()
}
return token, nil
@@ -133,7 +134,7 @@ func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string)
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil || matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
return "", apperror.OidcInvalidCallbackURL()
}
return matched, nil

View File

@@ -13,6 +13,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -58,8 +59,7 @@ func TestLogoutCallbackURL(t *testing.T) {
t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) {
_, err := logoutCallbackURL(withURLs, "https://evil.example/steal")
var target *common.OidcInvalidCallbackURLError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL))
})
}
@@ -144,15 +144,13 @@ func TestEndSessionService(t *testing.T) {
t.Run("missing id_token_hint is rejected", func(t *testing.T) {
service, _ := newService(t)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
})
t.Run("malformed id_token_hint is rejected", func(t *testing.T) {
service, _ := newService(t)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
})
t.Run("token signed by a foreign key is rejected", func(t *testing.T) {
@@ -166,8 +164,7 @@ func TestEndSessionService(t *testing.T) {
require.NoError(t, err)
_, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
})
t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) {
@@ -176,24 +173,21 @@ func TestEndSessionService(t *testing.T) {
service, _ := newService(t)
token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
})
t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
})
t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, validToken)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID)
var target *common.OidcClientIdNotMatchingError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeOidcClientIDNotMatching))
})
t.Run("user that never authorized the client is rejected", func(t *testing.T) {
@@ -201,8 +195,7 @@ func TestEndSessionService(t *testing.T) {
// A valid token for a user that has no authorization record for the client.
token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user")
var target *common.OidcMissingAuthorizationError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeOidcMissingAuthorization))
})
t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) {
@@ -212,8 +205,7 @@ func TestEndSessionService(t *testing.T) {
IdTokenHint: token,
PostLogoutRedirectUri: "https://evil.example/steal",
}, userID)
var target *common.OidcInvalidCallbackURLError
require.ErrorAs(t, err, &target)
require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL))
})
t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) {

View File

@@ -19,7 +19,7 @@ import (
"github.com/ory/fosite"
)
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" // #nosec G101 -- OAuth assertion type identifier, not a credential
var errNoFederatedClientAssertion = errors.New("no federated client assertion")

View File

@@ -38,7 +38,8 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -142,7 +143,7 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{
BaseURL: baseURL,
TokenBaseURL: baseURL,
Secret: []byte("test-secret"),

View File

@@ -17,7 +17,8 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -66,7 +67,8 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),

View File

@@ -58,7 +58,8 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -139,7 +140,8 @@ func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
CallbackURLs: datatype.StringList{"http://client.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -176,7 +178,8 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -209,7 +212,8 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
IsPublic: true,
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -241,7 +245,8 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -281,7 +286,8 @@ func TestProviderAcceptsUnsignedRequestObject(t *testing.T) {
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -322,7 +328,8 @@ func TestProviderRejectsSignedRequestObject(t *testing.T) {
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -398,7 +405,8 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error)
provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
@@ -468,7 +476,8 @@ func TestProviderIgnoresUnknownScopes(t *testing.T) {
CallbackURLs: datatype.StringList{"https://app.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
// #nosec G101
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),

View File

@@ -7,8 +7,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -31,12 +32,11 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
// @Param body body tokenCreateDto true "Token options"
// @Success 201 {object} object "{ \"token\": \"string\" }"
// @Router /api/users/{id}/one-time-access-token [post]
func (h *handler) createTokenForUser(c *gin.Context) {
func (h *handler) createTokenForUser(c *gin.Context) error {
var input tokenCreateDto
err := c.ShouldBindJSON(&input)
err := httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
// Get the target user ID from the URL and apply the default expiration when no TTL is provided
@@ -46,17 +46,16 @@ func (h *handler) createTokenForUser(c *gin.Context) {
ttl = defaultTokenDuration
}
if userID == "" {
_ = c.Error(&common.UserIdNotProvidedError{})
return
return apperror.MissingField("userId")
}
token, err := h.service.CreateToken(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, gin.H{"token": token})
return nil
}
// requestEmailAsUnauthenticatedUser godoc
@@ -68,28 +67,26 @@ func (h *handler) createTokenForUser(c *gin.Context) {
// @Param body body emailAsUnauthenticatedUserDto true "Email request information"
// @Success 204 "No Content"
// @Router /api/one-time-access-email [post]
func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) {
func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input emailAsUnauthenticatedUserDto
err = dto.ShouldBindWithNormalizedJSON(c, &input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
deviceToken, err := h.service.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), dbConfig, input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
return err
}
cookie.AddDeviceTokenCookie(c, deviceToken)
c.Status(http.StatusNoContent)
return nil
}
// requestEmailAsAdmin godoc
@@ -102,18 +99,16 @@ func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) {
// @Param body body emailAsAdminDto true "Email request options"
// @Success 204 "No Content"
// @Router /api/users/{id}/one-time-access-email [post]
func (h *handler) requestEmailAsAdmin(c *gin.Context) {
func (h *handler) requestEmailAsAdmin(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input emailAsAdminDto
err = c.ShouldBindJSON(&input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
userID := c.Param("id")
@@ -124,11 +119,11 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) {
}
err = h.service.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), dbConfig, userID, ttl)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// exchangeToken godoc
@@ -138,36 +133,33 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) {
// @Param token path string true "One-time access token"
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (h *handler) exchangeToken(c *gin.Context) {
func (h *handler) exchangeToken(c *gin.Context) error {
cfg, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
_ = c.Error(&common.TokenInvalidOrExpiredError{})
return
return apperror.TokenInvalidOrExpired()
}
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := h.service.ExchangeToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -69,8 +70,8 @@ func New(deps Dependencies) (*Module, error) {
// RegisterRoutes mounts the one-time access token endpoints
// auth guards the admin routes, while the rate limiters throttle the public exchange and email endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, exchangeRateLimit, emailRateLimit gin.HandlerFunc) {
apiGroup.POST("/users/:id/one-time-access-token", auth, m.handler.createTokenForUser)
apiGroup.POST("/users/:id/one-time-access-email", auth, m.handler.requestEmailAsAdmin)
apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, m.handler.exchangeToken)
apiGroup.POST("/one-time-access-email", emailRateLimit, m.handler.requestEmailAsUnauthenticatedUser)
apiGroup.POST("/users/:id/one-time-access-token", auth, httpserver.Handle(m.handler.createTokenForUser))
apiGroup.POST("/users/:id/one-time-access-email", auth, httpserver.Handle(m.handler.requestEmailAsAdmin))
apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, httpserver.Handle(m.handler.exchangeToken))
apiGroup.POST("/one-time-access-email", emailRateLimit, httpserver.Handle(m.handler.requestEmailAsUnauthenticatedUser))
}

View File

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -50,7 +51,7 @@ func newService(deps Dependencies, actorService *actor.Service) *Service {
func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
return &common.OneTimeAccessDisabledError{}
return apperror.OneTimeAccessDisabled()
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
@@ -59,7 +60,7 @@ func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig
func (s *Service) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
return "", &common.OneTimeAccessDisabledError{}
return "", apperror.OneTimeAccessDisabled()
}
var userId string
@@ -89,7 +90,7 @@ func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID,
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
return nil, apperror.UserEmailNotSet()
}
oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
@@ -134,7 +135,7 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat
// Load the user to ensure it exists
_, err = s.userProvider.GetUser(ctx, userID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
return "", apperror.UserNotFound()
} else if err != nil {
return "", err
}
@@ -167,9 +168,9 @@ func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConf
switch consumeRes.Status {
case tokenConsumeNotFound:
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
return model.User{}, "", apperror.TokenInvalidOrExpired()
case tokenConsumeDeviceMismatch:
return model.User{}, "", &common.DeviceCodeInvalid{}
return model.User{}, "", apperror.DeviceCodeInvalid()
case tokenConsumeOK:
// All good, continue below
default:
@@ -195,13 +196,13 @@ func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
return model.User{}, "", apperror.TokenInvalidOrExpired()
} else if err != nil {
return model.User{}, "", err
}
if user.Disabled {
return model.User{}, "", &common.UserDisabledError{}
return model.User{}, "", apperror.UserDisabled()
}
accessToken, err := s.signer.GenerateAccessToken(

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -128,8 +128,7 @@ func TestExchangeTokenInvalidToken(t *testing.T) {
dbConfig := appconfig.NewTestConfig(nil)
_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
}
func TestExchangeTokenDeviceMismatch(t *testing.T) {
@@ -150,8 +149,7 @@ func TestExchangeTokenDeviceMismatch(t *testing.T) {
dbConfig := appconfig.NewTestConfig(nil)
_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
var deviceErr *common.DeviceCodeInvalid
require.ErrorAs(t, err, &deviceErr)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceCodeInvalid))
// The token must not have been consumed on a device-token mismatch
var state TokenState
@@ -178,8 +176,7 @@ func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)

View File

@@ -9,7 +9,7 @@ import (
"strings"
"sync"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
@@ -40,7 +40,7 @@ func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCl
reader, size, err := s.storage.Open(ctx, imagePath)
if err != nil {
if storage.IsNotExist(err) {
return nil, 0, "", &common.ImageNotFoundError{}
return nil, 0, "", apperror.ImageNotFound()
}
return nil, 0, "", err
}
@@ -51,7 +51,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
return apperror.UnsupportedFileType("")
}
s.mu.Lock()
@@ -96,7 +96,7 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
ext, ok := s.extensions[imageName]
if !ok || ext == "" {
return &common.ImageNotFoundError{}
return apperror.ImageNotFound()
}
imagePath := path.Join("application-images", imageName+"."+ext)
@@ -122,7 +122,7 @@ func (s *AppImagesService) getExtension(name string) (string, error) {
ext, ok := s.extensions[name]
if !ok || ext == "" {
return "", &common.ImageNotFoundError{}
return "", apperror.ImageNotFound()
}
return strings.ToLower(ext), nil

View File

@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
@@ -85,15 +85,13 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
t.Run("get missing image returns not found", func(t *testing.T) {
_, _, _, err := service.GetImage(context.Background(), "missing")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
})
t.Run("reject unsupported file types", func(t *testing.T) {
err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
assert.ErrorAs(t, err, &fileTypeErr)
assert.True(t, apperror.IsCode(err, apperror.CodeFileTypeNotSupported))
})
t.Run("delete and extension tracking", func(t *testing.T) {
@@ -105,8 +103,7 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
err := service.DeleteImage(context.Background(), "default-profile-picture")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
})
}

View File

@@ -2,8 +2,10 @@ package service
import (
"context"
"errors"
"fmt"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
@@ -63,6 +65,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, user
tx.Rollback()
}()
// Reject missing owners before replacing claims so invalid IDs cannot appear successful
if err := ensureCustomClaimOwnerExists(ctx, tx, UserID, userID); err != nil {
return nil, err
}
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
if err != nil {
return nil, err
@@ -83,6 +90,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context,
tx.Rollback()
}()
// Reject missing owners before replacing claims so invalid IDs cannot appear successful
if err := ensureCustomClaimOwnerExists(ctx, tx, UserGroupID, userGroupID); err != nil {
return nil, err
}
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
if err != nil {
return nil, err
@@ -96,13 +108,42 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context,
return updatedClaims, nil
}
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
func ensureCustomClaimOwnerExists(ctx context.Context, tx *gorm.DB, ownerType idType, ownerID string) error {
// Select the owner model and corresponding client-safe not-found error
var (
target any
notFound error
)
switch ownerType {
case UserID:
target = &model.User{}
notFound = apperror.UserNotFound()
case UserGroupID:
target = &model.UserGroup{}
notFound = apperror.NotFound("User group")
default:
return fmt.Errorf("unsupported custom claim owner type %q", ownerType)
}
// Verify the owner in the active transaction before changing its claims
err := tx.WithContext(ctx).
Select("id").
First(target, "id = ?", ownerID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return notFound
}
return err
}
// updateCustomClaimsInternal keeps claim replacements within the caller's transaction
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
// Reject duplicate keys before changing persisted claims
seenKeys := make(map[string]struct{})
for _, claim := range claims {
if _, ok := seenKeys[claim.Key]; ok {
return nil, &common.DuplicateClaimError{Key: claim.Key}
return nil, apperror.DuplicateClaim(claim.Key)
}
seenKeys[claim.Key] = struct{}{}
}
@@ -117,7 +158,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
return nil, err
}
// Delete claims that are not in the new list
// Remove stale claims before applying the requested replacement set
for _, existingClaim := range existingClaims {
found := false
for _, claim := range claims {
@@ -138,10 +179,10 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
}
}
// Add or update claims
// Reject reserved keys and persist each requested claim
for _, claim := range claims {
if isReservedClaim(claim.Key) {
return nil, &common.ReservedClaimError{Key: claim.Key}
return nil, apperror.ReservedClaim(claim.Key)
}
customClaim := model.CustomClaim{
Key: claim.Key,
@@ -155,7 +196,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
customClaim.UserGroupID = &value
}
// Update the claim if it already exists or create a new one
// Preserve claim identity when updating an existing owner and key pair
err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
@@ -167,7 +208,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
}
}
// Get the updated claims
// Return the persisted replacement set to the caller
var updatedClaims []model.CustomClaim
err = tx.
WithContext(ctx).

View File

@@ -0,0 +1,19 @@
package service
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/require"
)
func TestCustomClaimUpdatesRejectMissingOwner(t *testing.T) {
service := NewCustomClaimService(testutils.NewDatabaseForTest(t))
_, err := service.UpdateCustomClaimsForUser(t.Context(), "missing-user", nil)
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
_, err = service.UpdateCustomClaimsForUserGroup(t.Context(), "missing-group", nil)
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}

View File

@@ -5,7 +5,6 @@ import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
@@ -24,7 +23,7 @@ import (
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -84,7 +83,7 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserServi
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
if !dbConfig.LdapEnabled.IsTrue() {
return nil, fmt.Errorf("LDAP is not enabled")
return nil, apperror.LdapDisabled()
}
// Setup LDAP connection
@@ -522,7 +521,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig)
if errors.Is(err, &common.AlreadyInUseError{}) {
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
@@ -533,7 +532,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
ldapUsersByID[desiredUser.ldapID] = createdUser
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig)
if errors.Is(err, &common.AlreadyInUseError{}) {
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
@@ -573,7 +572,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig)
if err != nil {
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
if apperror.IsCode(err, apperror.CodeLdapUserUpdate) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)

View File

@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -19,6 +20,14 @@ type fakeLDAPClient struct {
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
}
func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) {
service := NewLdapService(nil, nil, nil, nil, nil)
_, err := service.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled))
}
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
if c.searchFn == nil {
return nil, nil

View File

@@ -16,7 +16,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -85,14 +85,14 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
// for a CIMD client, bypassing the cache TTL, and returns the refreshed client.
func (s *OidcService) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) {
if s.metadataRefresher == nil {
return model.OidcClient{}, &common.ValidationError{Message: "client ID metadata documents are not enabled"}
return model.OidcClient{}, apperror.ValidationMessage("Client ID metadata documents are not enabled")
}
client, err := s.metadataRefresher.RefreshClientMetadata(ctx, clientID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.OidcClient{}, err
}
return model.OidcClient{}, &common.ValidationError{Message: err.Error()}
return model.OidcClient{}, apperror.ValidationMessage(err.Error())
}
return client, nil
}
@@ -107,6 +107,9 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
q = q.First(&client, "id = ?", clientID)
if errors.Is(q.Error, gorm.ErrRecordNotFound) {
return model.OidcClient{}, apperror.NotFound("OIDC client")
}
if q.Error != nil {
return model.OidcClient{}, q.Error
}
@@ -155,7 +158,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
return model.OidcClient{}, apperror.ClientIDAlreadyExists()
}
return model.OidcClient{}, err
}
@@ -284,14 +287,16 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
var client model.OidcClient
err := s.db.
result := s.db.
WithContext(ctx).
Where("id = ?", clientID).
Clauses(clause.Returning{}).
Delete(&client).
Error
if err != nil {
return err
Delete(&client)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return apperror.NotFound("OIDC client")
}
// Delete images if present
@@ -314,17 +319,13 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
tx.Rollback()
}()
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
client, err := s.getClientInternal(ctx, clientID, tx, true)
if err != nil {
return "", err
}
if client.IsPublic {
return "", &common.ValidationError{Message: "cannot create a secret for a public client"}
return "", apperror.ValidationMessage("Cannot create a secret for a public client")
}
clientSecret := input.Secret
@@ -358,11 +359,7 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
}
func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) {
var client model.OidcClient
err := s.db.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
client, err := s.getClientInternal(ctx, clientID, s.db, false)
if err != nil {
return nil, 0, "", err
}
@@ -378,7 +375,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
// Light logo if requested or no dark logo is available
ext = *client.ImageType
default:
return nil, 0, "", errors.New("image not found")
return nil, 0, "", apperror.ImageNotFound()
}
mimeType := utils.GetImageMimeType(ext)
@@ -388,6 +385,9 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
key := oidcClientImagePath(client.ID, suffix, ext)
reader, size, err := s.fileStorage.Open(ctx, key)
if err != nil {
if storage.IsNotExist(err) {
return nil, 0, "", apperror.ImageNotFound()
}
return nil, 0, "", err
}
@@ -397,7 +397,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error {
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return &common.FileTypeNotSupportedError{}
return apperror.UnsupportedFileType("")
}
var darkSuffix string
@@ -432,7 +432,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
return s.deleteClientLogoInternal(ctx, clientID, "", func(client *model.OidcClient) (string, error) {
if client.ImageType == nil {
return "", errors.New("image not found")
return "", apperror.ImageNotFound()
}
oldImageType := *client.ImageType
@@ -444,7 +444,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error {
return s.deleteClientLogoInternal(ctx, clientID, "-dark", func(client *model.OidcClient) (string, error) {
if client.DarkImageType == nil {
return "", errors.New("image not found")
return "", apperror.ImageNotFound()
}
oldImageType := *client.DarkImageType
@@ -459,11 +459,7 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str
tx.Rollback()
}()
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
client, err := s.getClientInternal(ctx, clientID, tx, true)
if err != nil {
return err
}
@@ -555,8 +551,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
tx.Rollback()
}()
var client model.OidcClient
err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error
client, err := s.getClientInternal(ctx, id, tx, false)
if err != nil {
return 0, err
}
@@ -566,8 +561,25 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
query := s.db.
var user model.User
err := tx.
WithContext(ctx).
Select("id").
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.PaginationResponse{}, apperror.UserNotFound()
}
if err != nil {
return nil, utils.PaginationResponse{}, err
}
query := tx.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Preload("Client").
@@ -590,6 +602,9 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
WithContext(ctx).
Where("user_id = ? AND client_id = ?", userID, clientID).
First(&authorizedClient).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound("Client authorization")
}
if err != nil {
return err
}
@@ -623,6 +638,9 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.PaginationResponse{}, apperror.UserNotFound()
}
if err != nil {
return nil, utils.PaginationResponse{}, err
}
@@ -692,12 +710,15 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperror.UserNotFound()
}
if err != nil {
return nil, err
}
if !oidc.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
return nil, apperror.OidcAccessDenied()
}
preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod)
@@ -711,8 +732,6 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
}, nil
}
var errLogoTooLarge = errors.New("logo is too large")
func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client {
if source == nil {
source = http.DefaultClient
@@ -732,7 +751,10 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error {
u, err := url.Parse(raw)
if err != nil {
return err
return apperror.InvalidLogoURL(err)
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return apperror.InvalidLogoURL(fmt.Errorf("URL must use HTTP or HTTPS and include a host"))
}
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
@@ -741,22 +763,22 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
// Prevents SSRF by allowing only public IPs
ok, err := utils.IsURLPrivate(ctx, u)
if err != nil {
return err
return apperror.LogoDownloadFailed(err)
} else if ok {
return errors.New("private IP addresses are not allowed")
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
}
// We need to check this on redirects too
client := httpClientWithCheckRedirect(s.httpClient, func(r *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects"))
}
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
if err != nil {
return err
} else if ok {
return errors.New("private IP addresses are not allowed")
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
}
return nil
@@ -764,24 +786,27 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return err
return apperror.InvalidLogoURL(err)
}
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
req.Header.Set("Accept", "image/*")
resp, err := client.Do(req)
if err != nil {
return err
if appErr, ok := errors.AsType[*apperror.Error](err); ok {
return appErr
}
return apperror.LogoDownloadFailed(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch logo: %s", resp.Status)
return apperror.LogoDownloadFailed(fmt.Errorf("logo server returned %s", resp.Status))
}
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
if resp.ContentLength > maxLogoSize {
return errLogoTooLarge
return apperror.LogoTooLarge("2 MB")
}
// Prefer extension in path if supported
@@ -792,7 +817,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
}
if ext == "" {
return &common.FileTypeNotSupportedError{}
return apperror.LogoTypeNotSupported()
}
var darkSuffix string
@@ -803,17 +828,17 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
limitReader := utils.NewLimitReader(resp.Body, maxLogoSize+1)
strippedReader, err := imageutil.StripMetadata(limitReader, ext)
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
return apperror.LogoTooLarge("2 MB")
} else if err != nil {
return err
return apperror.LogoDownloadFailed(err)
}
imagePath := oidcClientImagePath(clientID, darkSuffix, ext)
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
return apperror.LogoTooLarge("2 MB")
} else if err != nil {
return err
return apperror.LogoDownloadFailed(err)
}
err = s.updateClientLogoType(ctx, clientID, ext, light)
@@ -843,6 +868,9 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string,
First(&client, "id = ?", clientID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound("OIDC client")
}
return fmt.Errorf("failed to look up client: %w", err)
}
@@ -892,6 +920,9 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
First(&provider, "oidc_client_id = ?", clientID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
}
return model.ScimServiceProvider{}, err
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -22,6 +22,14 @@ import (
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestListAuthorizedClientsRejectsMissingUser(t *testing.T) {
service := &OidcService{db: testutils.NewDatabaseForTest(t)}
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", utils.ListRequestOptions{})
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
}
func TestOidcService_DeleteClientDeletesOAuth2Sessions(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Exec("PRAGMA foreign_keys = ON").Error)
@@ -193,7 +201,7 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
t.Run("Returns error for non-existent client", func(t *testing.T) {
err := s.updateClientLogoType(t.Context(), "non-existent-client-id", "png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
})
}
@@ -403,6 +411,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "://invalid-url", true)
require.Error(t, err)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
})
t.Run("Returns error for non-200 status code", func(t *testing.T) {
@@ -424,7 +433,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to fetch logo")
require.True(t, apperror.IsCode(err, apperror.CodeLogoDownloadFailed))
})
t.Run("Returns error for too large content", func(t *testing.T) {
@@ -454,7 +463,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
require.Error(t, err)
require.ErrorIs(t, err, errLogoTooLarge)
require.True(t, apperror.IsCode(err, apperror.CodeLogoTooLarge))
})
t.Run("Returns error for unsupported file type", func(t *testing.T) {
@@ -480,8 +489,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
require.ErrorAs(t, err, &fileTypeErr)
require.True(t, apperror.IsCode(err, apperror.CodeLogoTypeNotSupported))
})
t.Run("Returns error for non-existent client", func(t *testing.T) {
@@ -507,7 +515,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
})
}

View File

@@ -17,6 +17,7 @@ import (
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -73,6 +74,9 @@ func (s *ScimService) GetServiceProvider(
Preload("OidcClient.AllowedUserGroups").
First(&provider, "id = ?", serviceProviderID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
}
if err != nil {
return model.ScimServiceProvider{}, err
}
@@ -94,13 +98,25 @@ func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimSer
func (s *ScimService) CreateServiceProvider(
ctx context.Context,
input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil {
return model.ScimServiceProvider{}, err
}
provider := model.ScimServiceProvider{
Endpoint: input.Endpoint,
Token: datatype.EncryptedString(input.Token),
OidcClientID: input.OidcClientID,
}
if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil {
if err := tx.WithContext(ctx).Create(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
if err := tx.Commit().Error; err != nil {
return model.ScimServiceProvider{}, err
}
@@ -111,29 +127,64 @@ func (s *ScimService) UpdateServiceProvider(ctx context.Context,
serviceProviderID string,
input *dto.ScimServiceProviderCreateDTO,
) (model.ScimServiceProvider, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var provider model.ScimServiceProvider
err := s.db.WithContext(ctx).
err := tx.WithContext(ctx).
First(&provider, "id = ?", serviceProviderID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
}
if err != nil {
return model.ScimServiceProvider{}, err
}
if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil {
return model.ScimServiceProvider{}, err
}
provider.Endpoint = input.Endpoint
provider.Token = datatype.EncryptedString(input.Token)
provider.OidcClientID = input.OidcClientID
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
if err := tx.WithContext(ctx).Save(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
if err := tx.Commit().Error; err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
return s.db.WithContext(ctx).
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID).
func ensureScimOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error {
var client model.OidcClient
err := db.WithContext(ctx).
Select("id").
First(&client, "id = ?", clientID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound("OIDC client")
}
return err
}
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
result := s.db.WithContext(ctx).
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return apperror.NotFound("SCIM service provider")
}
return nil
}
//nolint:contextcheck

View File

@@ -0,0 +1,61 @@
package service
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/require"
)
func TestScimServiceProviderOperationsReturnSpecificNotFoundErrors(t *testing.T) {
service := NewScimService(testutils.NewDatabaseForTest(t), nil, nil)
_, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{
Endpoint: "https://scim.example.com",
OidcClientID: "missing-client",
})
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
_, err = service.GetServiceProvider(t.Context(), "missing-provider")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
err = service.DeleteServiceProvider(t.Context(), "missing-provider")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}
func TestScimServiceProviderCreateAndUpdate(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := NewScimService(db, nil, nil)
// Create two clients so provider creation and reassignment both satisfy the foreign key
require.NoError(t, db.Create(&[]model.OidcClient{
{Base: model.Base{ID: "client-1"}, Name: "Client 1"},
{Base: model.Base{ID: "client-2"}, Name: "Client 2"},
}).Error)
// Create the provider with its initial client in one transaction
provider, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{
Endpoint: "https://scim.example.com/v1",
OidcClientID: "client-1",
})
require.NoError(t, err)
require.NotEmpty(t, provider.ID)
// Move the provider to the second client in one transaction
provider, err = service.UpdateServiceProvider(t.Context(), provider.ID, &dto.ScimServiceProviderCreateDTO{
Endpoint: "https://scim.example.com/v2",
OidcClientID: "client-2",
})
require.NoError(t, err)
require.Equal(t, "https://scim.example.com/v2", provider.Endpoint)
require.Equal(t, "client-2", provider.OidcClientID)
// Verify the committed provider retains both updated values
persisted, err := service.GetServiceProvider(t.Context(), provider.ID)
require.NoError(t, err)
require.Equal(t, provider.Endpoint, persisted.Endpoint)
require.Equal(t, provider.OidcClientID, persisted.OidcClientID)
}

View File

@@ -9,7 +9,7 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -59,6 +59,9 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
Preload("AllowedOidcClients").
First(&group).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.UserGroup{}, apperror.NotFound("User group")
}
return group, err
}
@@ -74,13 +77,16 @@ func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigM
Where("id = ?", id).
First(&group).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound("User group")
}
if err != nil {
return err
}
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return &common.LdapUserGroupUpdateError{}
return apperror.LdapUserGroupUpdate()
}
err = tx.
@@ -123,7 +129,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
Create(&group).
Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
return model.UserGroup{}, apperror.AlreadyInUse("name")
} else if err != nil {
return model.UserGroup{}, err
}
@@ -163,7 +169,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
// Disallow updating the group if it is an LDAP group and LDAP is enabled
if !isLdapSync && group.LdapID != nil {
if cfg.LdapEnabled.IsTrue() {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
return model.UserGroup{}, apperror.LdapUserGroupUpdate()
}
}
@@ -177,7 +183,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
Save(&group).
Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
return model.UserGroup{}, apperror.AlreadyInUse("name")
} else if err != nil {
return model.UserGroup{}, err
}
@@ -269,6 +275,9 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (
Where("id = ?", id).
First(&group).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, apperror.NotFound("User group")
}
if err != nil {
return 0, err
}

View File

@@ -17,7 +17,7 @@ import (
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -80,13 +80,16 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
Where("id = ?", userID).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, apperror.UserNotFound()
}
return user, err
}
func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{}
return nil, 0, apperror.InvalidUserID()
}
user, err := s.GetUser(ctx, userID)
@@ -110,7 +113,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
if err == nil {
return reader, size, nil
}
if !errors.Is(err, &common.ImageNotFoundError{}) {
if !apperror.IsCode(err, apperror.CodeImageNotFound) {
return nil, 0, err
}
}
@@ -153,6 +156,9 @@ func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model
Where("id = ?", userID).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperror.UserNotFound()
}
if err != nil {
return nil, err
}
@@ -163,11 +169,18 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f
// Validate the user ID to prevent directory traversal
err := uuid.Validate(userID)
if err != nil {
return &common.InvalidUUIDError{}
return apperror.InvalidUserID()
}
if _, err := s.GetUser(ctx, userID); err != nil {
return err
}
// Convert the image to a smaller square image
profilePicture, err := profilepicture.CreateProfilePicture(file)
if errors.Is(err, profilepicture.ErrInvalidImage) {
return apperror.InvalidImage(err)
}
if err != nil {
return err
}
@@ -207,6 +220,9 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.UserNotFound()
}
if err != nil {
return fmt.Errorf("failed to load user to delete: %w", err)
}
@@ -214,7 +230,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
if !allowLdapDelete && !user.Disabled && user.LdapID != nil {
if cfg.LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{}
return apperror.LdapUserUpdate()
}
}
@@ -255,7 +271,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfi
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
return model.User{}, apperror.MissingField("email")
}
var userGroups []model.UserGroup
@@ -438,7 +454,7 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
return model.User{}, apperror.MissingField("email")
}
var user model.User
@@ -448,6 +464,9 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, apperror.UserNotFound()
}
if err != nil {
return model.User{}, err
}
@@ -588,7 +607,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "email"}
return apperror.AlreadyInUse("email")
}
err = tx.
@@ -600,7 +619,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "username"}
return apperror.AlreadyInUse("username")
}
return nil
@@ -610,7 +629,11 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
return apperror.InvalidUserID()
}
if _, err := s.GetUser(ctx, userID); err != nil {
return err
}
profilePicturePath := path.Join("profile-pictures", userID+".png")

View File

@@ -2,11 +2,14 @@ package service
import (
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -34,6 +37,48 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
return userService, groupService
}
func TestUserAndGroupLookupsReturnSpecificNotFoundErrors(t *testing.T) {
userService, groupService := newTestUserService(t)
_, err := userService.GetUser(t.Context(), "missing-user")
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
_, err = groupService.Get(t.Context(), "missing-group")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
var appErr *apperror.Error
require.ErrorAs(t, err, &appErr)
require.Equal(t, "User group", appErr.Details()["resource"])
}
func TestUpdateProfilePictureRejectsInvalidImageData(t *testing.T) {
userService, _ := newTestUserService(t)
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
ID: uuid.NewString(),
Username: "image-test",
})
require.NoError(t, err)
err = userService.UpdateProfilePicture(
t.Context(),
user.ID,
strings.NewReader("not an image"),
)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidImage))
}
func TestProfilePictureUpdatesRejectMissingUser(t *testing.T) {
userService, _ := newTestUserService(t)
missingUserID := uuid.NewString()
err := userService.UpdateProfilePicture(t.Context(), missingUserID, strings.NewReader("not an image"))
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
err = userService.ResetProfilePicture(t.Context(), missingUserID)
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
}
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
userService, groupService := newTestUserService(t)

View File

@@ -7,8 +7,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -24,19 +25,18 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) error {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
return apperror.SetupNotAvailable()
}
c.Status(http.StatusNoContent)
return nil
}
// signUpInitialAdmin godoc
@@ -48,37 +48,34 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
// @Param body body signUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (h *handler) signUpInitialAdmin(c *gin.Context) {
func (h *handler) signUpInitialAdmin(c *gin.Context) error {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input signUpDto
err = dto.ShouldBindWithNormalizedJSON(c, &input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return nil
}
// createSignupTokenHandler godoc
@@ -90,11 +87,10 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) {
// @Param token body signupTokenCreateDto true "Signup token information"
// @Success 201 {object} signupTokenDto
// @Router /api/signup-tokens [post]
func (h *handler) createSignupToken(c *gin.Context) {
func (h *handler) createSignupToken(c *gin.Context) error {
var input signupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
ttl := input.TTL.Duration
@@ -104,18 +100,17 @@ func (h *handler) createSignupToken(c *gin.Context) {
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
return err
}
var tokenDto signupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, tokenDto)
return nil
}
// listSignupTokensHandler godoc
@@ -128,26 +123,25 @@ func (h *handler) createSignupToken(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[signupTokenDto]
// @Router /api/signup-tokens [get]
func (h *handler) listSignupTokens(c *gin.Context) {
func (h *handler) listSignupTokens(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
var tokensDto []signupTokenDto
err = dto.MapStructList(tokens, &tokensDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
return nil
}
// deleteSignupTokenHandler godoc
@@ -157,16 +151,16 @@ func (h *handler) listSignupTokens(c *gin.Context) {
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (h *handler) deleteSignupToken(c *gin.Context) {
func (h *handler) deleteSignupToken(c *gin.Context) error {
tokenID := c.Param("id")
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// signupHandler godoc
@@ -178,24 +172,21 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
// @Param user body signUpDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/signup [post]
func (h *handler) signup(c *gin.Context) {
func (h *handler) signup(c *gin.Context) error {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input signUpDto
err = dto.ShouldBindWithNormalizedJSON(c, &input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
@@ -204,9 +195,9 @@ func (h *handler) signup(c *gin.Context) {
var userDto dto.UserDto
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, userDto)
return nil
}

View File

@@ -18,7 +18,7 @@ import (
// It's loaded here to create the per-token actors on first startup.
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
const signupTokensMigratedKey = "signup_tokens_migrated" // #nosec G101 -- database key name, not a credential
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
// All timestamps are expressed as Unix seconds.

View File

@@ -11,6 +11,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -77,10 +78,10 @@ func (m *Module) RunSignupTokenMigration(ctx context.Context) error {
// RegisterRoutes mounts the signup and signup-token management endpoints
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) {
apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken)
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
apiGroup.POST("/signup-tokens", adminAuth, httpserver.Handle(m.handler.createSignupToken))
apiGroup.GET("/signup-tokens", adminAuth, httpserver.Handle(m.handler.listSignupTokens))
apiGroup.DELETE("/signup-tokens/:id", adminAuth, httpserver.Handle(m.handler.deleteSignupToken))
apiGroup.POST("/signup", signupRateLimit, httpserver.Handle(m.handler.signup))
apiGroup.GET("/signup/setup", httpserver.Handle(m.handler.checkInitialAdminSetupAvailable))
apiGroup.POST("/signup/setup", httpserver.Handle(m.handler.signUpInitialAdmin))
}

View File

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -46,7 +47,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
tokenProvided := signupData.Token != ""
if config.AllowUserSignups.String() != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
return model.User{}, "", apperror.OpenSignupDisabled()
}
var userGroupIDs []string
@@ -65,7 +66,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
}
if consumeRes.Status != signupTokenConsumeOK {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
return model.User{}, "", apperror.TokenInvalidOrExpired()
}
userGroupIDs = consumeRes.UserGroupIDs
}
@@ -160,7 +161,7 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppC
return model.User{}, "", err
}
if setupCompleted {
return model.User{}, "", &common.SetupNotAvailableError{}
return model.User{}, "", apperror.SetupAlreadyCompleted()
}
// Build the first user with administrator privileges

View File

@@ -10,7 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -114,8 +114,7 @@ func TestSignUpRejectsInvalidToken(t *testing.T) {
Token: "not-a-real-token",
}, "1.2.3.4", "test-agent")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
}
func TestSignUpInitialAdminCreatesAdmin(t *testing.T) {
@@ -154,8 +153,7 @@ func TestSignUpInitialAdminRejectsExistingInstallation(t *testing.T) {
// Reject setup when the installation already contains a user
_, _, err := svc.SignUpInitialAdmin(t.Context(), appconfig.NewTestConfig(nil), signUpDto{Username: "new-admin"})
var setupNotAvailableErr *common.SetupNotAvailableError
require.ErrorAs(t, err, &setupNotAvailableErr)
require.True(t, apperror.IsCode(err, apperror.CodeSetupAlreadyCompleted))
}
// listAllOptions returns list options that return every token on a single page.

View File

@@ -8,6 +8,9 @@ import (
)
func TestValidateCallbackURLPattern(t *testing.T) {
// #nosec G101
const wildcardUserinfoPattern = "https://user:*@example.com/callback"
tests := []struct {
name string
pattern string
@@ -35,7 +38,7 @@ func TestValidateCallbackURLPattern(t *testing.T) {
},
{
name: "wildcard userinfo",
pattern: "https://user:*@example.com/callback", // #nosec G101 - Test credential
pattern: wildcardUserinfoPattern,
shouldError: false,
},
{

View File

@@ -8,9 +8,9 @@ import (
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
var DeviceLoginTokenCookieName = "__Secure-device_login_token" //nolint:gosec
var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec
var DeviceTokenCookieName = "__Secure-device_token" // #nosec G101 -- cookie name, not a credential
var DeviceLoginTokenCookieName = "__Secure-device_login_token" // #nosec G101 -- cookie name, not a credential
var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" // #nosec G101 -- cookie name, not a credential
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {

View File

@@ -2,6 +2,7 @@ package profilepicture
import (
"bytes"
"errors"
"fmt"
"image"
"image/color"
@@ -19,6 +20,8 @@ import (
const profilePictureSize = 300
var ErrInvalidImage = errors.New("invalid image")
// CreateProfilePicture resizes the profile picture to a square and encodes it as PNG
func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) {
// Attempt standard formats first
@@ -30,7 +33,10 @@ func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) {
// Try WebP
webpImg, webpErr := webp.Decode(file)
if webpErr != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
return nil, fmt.Errorf("%w: %w", ErrInvalidImage, errors.Join(
fmt.Errorf("standard formats: %w", err),
fmt.Errorf("WebP: %w", webpErr),
))
}
img = webpImg

View File

@@ -7,8 +7,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -21,113 +22,105 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) beginRegistration(c *gin.Context) {
func (h *handler) beginRegistration(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
userID := c.GetString("userID")
options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID)
if err != nil {
_ = c.Error(err)
return
return err
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
return nil
}
func (h *handler) verifyRegistration(c *gin.Context) {
func (h *handler) verifyRegistration(c *gin.Context) error {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
return apperror.MissingSessionID()
}
userID := c.GetString("userID")
credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
if err != nil {
_ = c.Error(err)
return
return err
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, credentialDto)
return nil
}
func (h *handler) beginLogin(c *gin.Context) {
func (h *handler) beginLogin(c *gin.Context) error {
options, err := h.service.BeginLogin(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
return nil
}
func (h *handler) verifyLogin(c *gin.Context) {
func (h *handler) verifyLogin(c *gin.Context) error {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
return apperror.MissingSessionID()
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
_ = c.Error(err)
return
return apperror.InvalidWebAuthnResponse(err)
}
user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return nil
}
func (h *handler) listCredentials(c *gin.Context) {
func (h *handler) listCredentials(c *gin.Context) error {
userID := c.GetString("userID")
credentials, err := h.service.ListCredentials(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
return err
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, credentialDtos)
return nil
}
func (h *handler) deleteCredential(c *gin.Context) {
func (h *handler) deleteCredential(c *gin.Context) error {
userID := c.GetString("userID")
credentialID := c.Param("id")
clientIP := c.ClientIP()
@@ -135,48 +128,46 @@ func (h *handler) deleteCredential(c *gin.Context) {
err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
func (h *handler) updateCredential(c *gin.Context) {
func (h *handler) updateCredential(c *gin.Context) error {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil {
_ = c.Error(err)
return
return err
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, credentialDto)
return nil
}
func (h *handler) logout(c *gin.Context) {
func (h *handler) logout(c *gin.Context) error {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
return nil
}
func (h *handler) reauthenticate(c *gin.Context) {
func (h *handler) reauthenticate(c *gin.Context) error {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
return apperror.MissingSessionID()
}
var token string
@@ -186,19 +177,18 @@ func (h *handler) reauthenticate(c *gin.Context) {
if err == nil {
token, err = h.service.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
return err
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = h.service.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
return err
}
}
cookie.AddReauthenticationTokenCookie(c, token)
c.Status(http.StatusNoContent)
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -56,19 +57,19 @@ func New(deps Dependencies) (*Module, error) {
// RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) {
apiGroup.GET("/webauthn/register/start", userAuth, m.handler.beginRegistration)
apiGroup.POST("/webauthn/register/finish", userAuth, m.handler.verifyRegistration)
apiGroup.GET("/webauthn/register/start", userAuth, httpserver.Handle(m.handler.beginRegistration))
apiGroup.POST("/webauthn/register/finish", userAuth, httpserver.Handle(m.handler.verifyRegistration))
apiGroup.GET("/webauthn/login/start", m.handler.beginLogin)
apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin)
apiGroup.GET("/webauthn/login/start", httpserver.Handle(m.handler.beginLogin))
apiGroup.POST("/webauthn/login/finish", loginRateLimit, httpserver.Handle(m.handler.verifyLogin))
apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout)
apiGroup.POST("/webauthn/logout", userAuth, httpserver.Handle(m.handler.logout))
apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate)
apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, httpserver.Handle(m.handler.reauthenticate))
apiGroup.GET("/webauthn/credentials", userAuth, m.handler.listCredentials)
apiGroup.PATCH("/webauthn/credentials/:id", userAuth, m.handler.updateCredential)
apiGroup.DELETE("/webauthn/credentials/:id", userAuth, m.handler.deleteCredential)
apiGroup.GET("/webauthn/credentials", userAuth, httpserver.Handle(m.handler.listCredentials))
apiGroup.PATCH("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.updateCredential))
apiGroup.DELETE("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.deleteCredential))
}
// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface

View File

@@ -14,7 +14,7 @@ import (
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -26,6 +26,9 @@ const authenticationMethodPhishingResistant = "phr"
const defaultRPDisplayName = "Pocket ID"
// go-webauthn exposes the missing user-verification reason only through DevInfo
const missingUserVerificationErrorInfo = "User verification required but flag not set by authenticator"
type Service struct {
db *gorm.DB
webAuthn *gowebauthn.WebAuthn
@@ -79,8 +82,11 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App
err := tx.
WithContext(ctx).
Preload("Credentials").
Find(&user, "id = ?", userID).
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperror.UserNotFound()
}
if err != nil {
return nil, fmt.Errorf("failed to load user: %w", err)
}
@@ -138,7 +144,7 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
}
if result.RowsAffected == 0 {
return model.WebauthnCredential{}, &common.InvalidWebauthnSessionError{}
return model.WebauthnCredential{}, apperror.InvalidWebAuthnSession()
}
session := gowebauthn.SessionData{
@@ -152,15 +158,18 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
var user model.User
err := tx.
WithContext(ctx).
Find(&user, "id = ?", userID).
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.WebauthnCredential{}, apperror.UserNotFound()
}
if err != nil {
return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err)
}
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil {
return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err)
return model.WebauthnCredential{}, classifyPasskeyError(err, apperror.InvalidWebAuthnResponse)
}
// Determine passkey name using AAGUID and User-Agent
@@ -248,7 +257,7 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
}
if result.RowsAffected == 0 {
return model.User{}, "", &common.InvalidWebauthnSessionError{}
return model.User{}, "", apperror.InvalidWebAuthnSession()
}
session := gowebauthn.SessionData{
@@ -265,18 +274,25 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
// Preserve infrastructure failures through go-webauthn's wrapped callback error
if innerErr != nil {
if !errors.Is(innerErr, gorm.ErrRecordNotFound) {
return nil, apperror.Internal(innerErr)
}
return nil, innerErr
}
return user, nil
}, session, credentialAssertionData)
if err != nil {
return model.User{}, "", err
return model.User{}, "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
}
if user == nil {
return model.User{}, "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
}
if user.Disabled {
return model.User{}, "", &common.UserDisabledError{}
return model.User{}, "", apperror.UserDisabled()
}
token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes())
@@ -321,7 +337,7 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
return fmt.Errorf("failed to delete record: %w", result.Error)
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
return apperror.NotFound("Passkey")
}
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
@@ -331,6 +347,9 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
WithContext(ctx).
First(&actor, "id = ?", actorUserID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.UserNotFound()
}
if err != nil {
return fmt.Errorf("failed to load actor user: %w", err)
}
@@ -359,6 +378,9 @@ func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, na
Where("id = ? AND user_id = ?", credentialID, userID).
First(&credential).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.WebauthnCredential{}, apperror.NotFound("Passkey")
}
if err != nil {
return credential, err
}
@@ -394,26 +416,26 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context
token, err := s.signer.VerifyAccessToken(accessToken)
if err != nil {
return "", fmt.Errorf("invalid access token: %w", err)
return "", apperror.ReauthenticationRequiredWithCause(err)
}
userID, ok := token.Subject()
if !ok {
return "", errors.New("access token does not contain user ID")
return "", apperror.ReauthenticationRequiredWithCause(errors.New("access token does not contain user ID"))
}
authenticationMethod, err := s.signer.GetAuthenticationMethod(token)
if err != nil {
return "", err
return "", apperror.ReauthenticationRequiredWithCause(err)
}
if authenticationMethod != authenticationMethodPhishingResistant {
return "", &common.ReauthenticationRequiredError{}
return "", apperror.ReauthenticationRequired()
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
return "", &common.ReauthenticationRequiredError{}
return "", apperror.ReauthenticationRequired()
}
var user model.User
@@ -421,6 +443,9 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context
WithContext(ctx).
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", apperror.ReauthenticationRequiredWithCause(err)
}
if err != nil {
return "", fmt.Errorf("failed to load user: %w", err)
}
@@ -454,7 +479,7 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
return "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
}
if result.RowsAffected == 0 {
return "", &common.InvalidWebauthnSessionError{}
return "", apperror.InvalidWebAuthnSession()
}
session := gowebauthn.SessionData{
@@ -472,14 +497,21 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
// Preserve infrastructure failures through go-webauthn's wrapped callback error
if innerErr != nil {
if !errors.Is(innerErr, gorm.ErrRecordNotFound) {
return nil, apperror.Internal(innerErr)
}
return nil, innerErr
}
return user, nil
}, session, credentialAssertionData)
if err != nil || user == nil {
return "", err
if err != nil {
return "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
}
if user == nil {
return "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
}
// Create reauthentication token
@@ -496,6 +528,20 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
return token, nil
}
func classifyPasskeyError(err error, fallback func(error) *apperror.Error) *apperror.Error {
if appErr, ok := errors.AsType[*apperror.Error](err); ok {
return appErr
}
if protocolError, ok := errors.AsType[*protocol.Error](err); ok &&
protocolError.Type == protocol.ErrVerification.Type &&
protocolError.DevInfo == missingUserVerificationErrorInfo {
return apperror.PasskeyUserVerificationRequired(err)
}
return fallback(err)
}
func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) {
hashedToken := utils.CreateSha256Hash(token)
var reauthToken ReauthenticationToken
@@ -507,7 +553,7 @@ func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB,
return time.Time{}, result.Error
}
if result.RowsAffected == 0 {
return time.Time{}, &common.ReauthenticationRequiredError{}
return time.Time{}, apperror.ReauthenticationRequired()
}
return reauthToken.CreatedAt.UTC(), nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -105,7 +106,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
})
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
@@ -117,7 +118,16 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
})
t.Run("classifies an invalid access token as missing reauthentication", func(t *testing.T) {
service, _, _ := setupService(t)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), "invalid")
assert.Empty(t, reauthenticationToken)
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
})
}
@@ -133,6 +143,54 @@ func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) {
require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName)
}
func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) {
rpIDHash := make([]byte, 32)
authenticatorData := protocol.AuthenticatorData{
RPIDHash: rpIDHash,
Flags: protocol.FlagUserPresent,
}
cause := authenticatorData.Verify(rpIDHash, nil, true, true)
require.Error(t, cause)
err := classifyPasskeyError(cause, apperror.WebAuthnAuthenticationFailed)
require.True(t, apperror.IsCode(err, apperror.CodePasskeyUserVerificationRequired))
require.ErrorIs(t, err, cause)
other := protocol.ErrVerification.WithInfo("RP Hash mismatch")
err = classifyPasskeyError(other, apperror.WebAuthnAuthenticationFailed)
require.True(t, apperror.IsCode(err, apperror.CodeWebAuthnAuthenticationFailed))
}
func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) {
// Wrap a structured database failure exactly as go-webauthn wraps callback errors
cause := errors.New("database unavailable")
lookupErr := protocol.ErrBadRequest.WithError(apperror.Internal(cause))
// Verify the structured failure and diagnostic cause both survive classification
err := classifyPasskeyError(lookupErr, apperror.WebAuthnAuthenticationFailed)
require.True(t, apperror.IsCode(err, apperror.CodeInternal))
require.ErrorIs(t, err, cause)
}
func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) {
service, err := newService(Dependencies{
DB: testutils.NewDatabaseForTest(t),
AppURL: "https://example.com",
})
require.NoError(t, err)
_, err = service.BeginRegistration(t.Context(), &appconfig.AppConfigModel{}, "missing-user")
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
_, err = service.UpdateCredential(t.Context(), "missing-user", "missing-passkey", "New name")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
err = service.DeleteCredential(t.Context(), "missing-user", "missing-passkey", "", "", "")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
}
// A ceremony that references a session which does not exist must be rejected outright
// The delete-and-return leaves the struct zero-valued when nothing matched, and a zero session has an
// empty user verification requirement, a zero expiry and an empty challenge, so letting it reach the
@@ -158,7 +216,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
_, err := service.VerifyRegistration(t.Context(), "does-not-exist", userID, nil, "127.0.0.1")
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
})
t.Run("login rejects an unknown session", func(t *testing.T) {
@@ -168,7 +226,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
assert.Empty(t, token)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
})
t.Run("reauthentication rejects an unknown session", func(t *testing.T) {
@@ -178,7 +236,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
assert.Empty(t, token)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
})
// The reauthentication query filters expired rows out in SQL, so an expired session matches no row
@@ -197,7 +255,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
assert.Empty(t, token)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
})
}

View File

@@ -38,17 +38,21 @@
"expiration": "Expiration",
"name": "Name",
"an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"authentication_process_was_aborted": "Passkey verification was canceled",
"error_occurred_with_authenticator": "Something went wrong while using your passkey",
"authenticator_does_not_support_discoverable_credentials": "This device does not support the type of passkey required",
"authenticator_does_not_support_resident_keys": "This device does not support the type of passkey required",
"passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
"webauthn_error_invalid_domain": "The configured domain is invalid.",
"authenticator_does_not_support_any_of_the_requested_algorithms": "This device does not support a compatible passkey",
"webauthn_error_invalid_rp_id": "Passkeys are not configured correctly for this domain.",
"webauthn_error_invalid_domain": "Passkeys cannot be used on the configured domain.",
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
"webauthn_operation_not_allowed_or_timed_out": "The passkey prompt was canceled or timed out",
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"passkey_request_expired": "Your passkey request has expired",
"passkey_response_invalid": "We couldn't process the response from your passkey",
"passkey_verification_failed": "We couldn't verify your passkey",
"passkey_user_verification_required": "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"account_selection_signin_confirmation": "Do you want to use the following account to continue to {#b}{name}{/b}?",

View File

@@ -1,12 +1,14 @@
import type { HandleClientError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
import { isAxiosError } from 'axios';
import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util';
export const handleError: HandleClientError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
if (isAxiosError(error)) {
message = getAxiosErrorMessage(error, message);
status = error.response?.status || status;
console.error(
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
`Axios error: ${error.request?.path ?? 'unknown path'} - ${getAxiosErrorMessage(error, error.message)}`,
{ requestId: getAxiosErrorRequestId(error) }
);
} else {
console.error(error);

View File

@@ -1,17 +1,65 @@
import { m } from '$lib/paraglide/messages';
import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { isAxiosError } from 'axios';
import { toast } from 'svelte-sonner';
interface ApiErrorResponse {
error?: unknown;
code?: string;
request_id?: string;
}
const codeMessages: Record<string, () => string> = {
internal_error: () => m.an_unknown_error_occurred(),
request_timeout: () => m.please_try_again(),
rate_limited: () => m.please_try_again(),
not_signed_in: () => m.please_try_to_sign_in_again(),
reauthentication_required: () => m.please_try_to_sign_in_again(),
invalid_webauthn_session: () => m.passkey_request_expired(),
invalid_webauthn_response: () => m.passkey_response_invalid(),
webauthn_authentication_failed: () => m.passkey_verification_failed(),
passkey_user_verification_required: () => m.passkey_user_verification_required(),
device_login_expired: () => m.device_login_request_expired()
};
function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined {
if (!isAxiosError(e)) {
return undefined;
}
const data = e.response?.data;
if (!data || typeof data !== 'object') {
return undefined;
}
return data as ApiErrorResponse;
}
export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = m.an_unknown_error_occurred()
) {
let message = defaultMessage;
if (e instanceof AxiosError) {
message = e.response?.data.error || message;
const response = getApiErrorResponse(e);
if (!response) {
return defaultMessage;
}
return message;
const codeMessage = response.code ? codeMessages[response.code]?.() : undefined;
return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage);
}
export function getAxiosErrorRequestId(e: unknown): string | undefined {
if (!isAxiosError(e)) {
return undefined;
}
const bodyRequestId = getApiErrorResponse(e)?.request_id;
if (bodyRequestId) {
return bodyRequestId;
}
const headerRequestId = e.response?.headers?.['x-request-id'];
return typeof headerRequestId === 'string' ? headerRequestId : undefined;
}
export function axiosErrorToast(
@@ -39,13 +87,14 @@ export function getWebauthnErrorMessage(e: unknown) {
NotAllowedError: m.webauthn_operation_not_allowed_or_timed_out()
};
const response = getApiErrorResponse(e);
let message: string = m.an_unknown_error_occurred();
if (e instanceof WebAuthnError && e.code in errors) {
message = errors[e.code as keyof typeof errors];
} else if (e instanceof WebAuthnError && e.cause instanceof Error && e.cause.name in errors) {
message = errors[e.cause.name as keyof typeof errors];
} else if (e instanceof AxiosError && e.response?.data.error) {
message = e.response?.data.error;
} else if (isAxiosError(e) && (response?.code || response?.error)) {
message = getAxiosErrorMessage(e);
} else {
console.error(e);
}

View File

@@ -1,7 +1,9 @@
import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util';
import { setLocaleForLibraries } from '$lib/utils/locale.util';
import { getAuthRedirectPath } from '$lib/utils/redirection-util';
import { setTracingEnabled } from '$lib/utils/tracing-util';
@@ -17,8 +19,12 @@ export const load: LayoutLoad = async ({ url }) => {
const userPromise = userService.getCurrent().catch(() => null);
const appConfigPromise = appConfigService.list().catch((e) => {
const fallbackMessage = e instanceof Error ? e.message : m.an_unknown_error_occurred();
console.error(
`Failed to get application configuration: ${e.response?.data.error || e.message}`
`Failed to get application configuration: ${getAxiosErrorMessage(e, fallbackMessage)}`,
{
requestId: getAxiosErrorRequestId(e)
}
);
return null;
});

Some files were not shown because too many files have changed in this diff Show More