Compare commits

..

6 Commits

Author SHA1 Message Date
Kyle Mendell
8beb4921c1 coderabbit review stuff 2026-07-13 11:25:01 -05:00
Kyle Mendell
c87b9a97aa limits 2026-07-13 11:12:46 -05:00
Kyle Mendell
00d2b0fea9 Merge remote-tracking branch 'origin/main' into feat/huma-api-docs 2026-07-13 11:00:13 -05:00
Kyle Mendell
7b5d6f5a9c format api routes 2026-07-12 13:42:05 -05:00
Kyle Mendell
3893376cba Merge branch 'main' into feat/huma-api-docs 2026-07-12 12:48:50 -05:00
Kyle Mendell
f8b441fb06 feat: built in api documentation via huma gin adapter 2026-07-10 16:31:45 -05:00
103 changed files with 4057 additions and 4648 deletions

View File

@@ -11,6 +11,7 @@ require (
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/danielgtaylor/huma/v2 v2.38.0
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
@@ -20,7 +21,6 @@ require (
github.com/go-co-op/gocron/v2 v2.21.2
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
@@ -68,7 +68,7 @@ require (
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/alphadose/haxmap v1.4.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
@@ -117,6 +117,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect

View File

@@ -14,8 +14,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
@@ -82,6 +82,8 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -519,8 +521,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View File

@@ -1,6 +1,8 @@
package api
import (
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -23,25 +25,25 @@ type apiPermissionResponseDto struct {
// apiCreateDto is the payload for creating an API
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
type apiCreateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
Resource string `json:"resource" required:"true" maxLength:"350" unorm:"nfc"`
}
// apiUpdateDto is the payload for updating an API
// The resource identifier is intentionally not updatable
type apiUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
}
type apiPermissionInputDto struct {
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Description *string `json:"description" binding:"omitempty,max=200"`
Key string `json:"key" required:"true" minLength:"1" maxLength:"128" unorm:"nfc"`
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
Description *string `json:"description" required:"false" maxLength:"200"`
}
// apiPermissionsUpdateDto replaces the full permission set of an API
type apiPermissionsUpdateDto struct {
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
Permissions []apiPermissionInputDto `json:"permissions" required:"false"`
}
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
@@ -52,6 +54,28 @@ type clientApiAccessDto struct {
}
type clientApiAccessUpdateDto struct {
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" required:"false"`
ClientPermissionIDs []string `json:"clientPermissionIds" required:"false"`
}
func (d *apiCreateDto) Resolve(huma.Context) []error {
if dto.ValidateResourceURI(d.Resource) {
return nil
}
return []error{&huma.ErrorDetail{Location: "body.resource", Message: "Resource must be an absolute URI without whitespace or a fragment"}}
}
func (d *clientApiAccessUpdateDto) Resolve(huma.Context) []error {
var errs []error
for _, id := range d.UserDelegatedPermissionIDs {
if id == "" {
errs = append(errs, &huma.ErrorDetail{Location: "body.userDelegatedPermissionIds", Message: "Permission IDs cannot be empty"})
}
}
for _, id := range d.ClientPermissionIDs {
if id == "" {
errs = append(errs, &huma.ErrorDetail{Location: "body.clientPermissionIds", Message: "Permission IDs cannot be empty"})
}
}
return errs
}

View File

@@ -1,14 +1,44 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"context"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type listInput struct {
httpapi.ListInput
Search string `query:"search" required:"false"`
}
type idInput struct {
ID string `path:"id"`
}
type createInput struct {
Body apiCreateDto
}
type updateInput struct {
ID string `path:"id"`
Body apiUpdateDto
}
type permissionsInput struct {
ID string `path:"id"`
Body apiPermissionsUpdateDto
}
type clientInput struct {
ClientID string `path:"clientId"`
}
type clientUpdateInput struct {
ClientID string `path:"clientId"`
Body clientApiAccessUpdateDto
}
type handler struct {
service *Service
}
@@ -17,219 +47,93 @@ func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List APIs
// @Description Get a paginated list of APIs with optional search and sorting
// @Tags APIs
// @Produce json
// @Param search query string false "Search term to filter APIs by name or resource"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiResponseDto]
// @Router /api/apis [get]
func (h *handler) list(c *gin.Context) {
search := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
func (h *handler) list(ctx context.Context, input *listInput) (*httpapi.BodyOutput[dto.Paginated[apiResponseDto]], error) {
apis, pagination, err := h.service.List(ctx, input.Search, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
items := make([]apiResponseDto, len(apis))
for i, api := range apis {
var item apiResponseDto
if err := dto.MapStruct(api, &item); err != nil {
_ = c.Error(err)
return
for i := range apis {
if err := dto.MapStruct(apis[i], &items[i]); err != nil {
return nil, err
}
item.Resource = api.Audience
items[i] = item
items[i].Resource = apis[i].Audience
}
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
Data: items,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[apiResponseDto]]{Body: dto.Paginated[apiResponseDto]{Data: items, Pagination: pagination}}, nil
}
// get godoc
// @Summary Get API by ID
// @Description Retrieve a single API including its permissions
// @Tags APIs
// @Produce json
// @Param id path string true "API ID"
// @Success 200 {object} apiResponseDto
// @Router /api/apis/{id} [get]
func (h *handler) get(c *gin.Context) {
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
func (h *handler) get(ctx context.Context, input *idInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Get(ctx, nil, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
h.respond(c, http.StatusOK, api)
return mapAPI(item)
}
// create godoc
// @Summary Create API
// @Description Create a new API resource server
// @Tags APIs
// @Accept json
// @Produce json
// @Param api body apiCreateDto true "API information"
// @Success 201 {object} apiResponseDto "Created API"
// @Router /api/apis [post]
func (h *handler) create(c *gin.Context) {
var input apiCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Create(c.Request.Context(), input)
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Create(ctx, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
h.respond(c, http.StatusCreated, api)
return mapAPI(item)
}
// update godoc
// @Summary Update API
// @Description Update an existing API by ID
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param api body apiUpdateDto true "API information"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id} [put]
func (h *handler) update(c *gin.Context) {
var input apiUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
func (h *handler) update(ctx context.Context, input *updateInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Update(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
h.respond(c, http.StatusOK, api)
return mapAPI(item)
}
// delete godoc
// @Summary Delete API
// @Description Delete an API by ID
// @Tags APIs
// @Param id path string true "API ID"
// @Success 204 "No Content"
// @Router /api/apis/{id} [delete]
func (h *handler) delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
func (h *handler) delete(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) {
if err := h.service.Delete(ctx, input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// updatePermissions godoc
// @Summary Update API permissions
// @Description Replace the full set of permissions for an API
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id}/permissions [put]
func (h *handler) updatePermissions(c *gin.Context) {
var input apiPermissionsUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
func (h *handler) updatePermissions(ctx context.Context, input *permissionsInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.UpdatePermissions(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
h.respond(c, http.StatusOK, api)
return mapAPI(item)
}
// getClientAccess godoc
// @Summary Get client API access
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [get]
func (h *handler) getClientAccess(c *gin.Context) {
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
func (h *handler) getClientAccess(ctx context.Context, input *clientInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
access, err := h.service.GetClientAPIAccess(ctx, input.ClientID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, newClientApiAccessDto(access))
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(access)}, nil
}
// updateClientAccess godoc
// @Summary Update client API access
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Accept json
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [put]
func (h *handler) updateClientAccess(c *gin.Context) {
var input clientApiAccessUpdateDto
err := c.ShouldBindJSON(&input)
func (h *handler) updateClientAccess(ctx context.Context, input *clientUpdateInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
applied, err := h.service.SetClientAPIAccess(ctx, input.ClientID, ClientAPIAccess(input.Body))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(applied)}, nil
}
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
dto := clientApiAccessDto(access)
if dto.UserDelegatedPermissionIDs == nil {
dto.UserDelegatedPermissionIDs = []string{}
func newClientAPIAccessDTO(access ClientAPIAccess) clientApiAccessDto {
output := clientApiAccessDto(access)
if output.UserDelegatedPermissionIDs == nil {
output.UserDelegatedPermissionIDs = []string{}
}
if dto.ClientPermissionIDs == nil {
dto.ClientPermissionIDs = []string{}
if output.ClientPermissionIDs == nil {
output.ClientPermissionIDs = []string{}
}
return dto
return output
}
func (h *handler) respond(c *gin.Context, status int, api API) {
var responseDto apiResponseDto
if err := dto.MapStruct(api, &responseDto); err != nil {
_ = c.Error(err)
return
func mapAPI(item API) (*httpapi.BodyOutput[apiResponseDto], error) {
var output apiResponseDto
if err := dto.MapStruct(item, &output); err != nil {
return nil, err
}
responseDto.Resource = api.Audience
c.JSON(status, responseDto)
output.Resource = item.Audience
return &httpapi.BodyOutput[apiResponseDto]{Body: output}, nil
}

View File

@@ -2,12 +2,14 @@ package api
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type Dependencies struct {
@@ -59,20 +61,70 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
}
// RegisterRoutes mounts the admin CRUD endpoints
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
apis := apiGroup.Group("/apis")
apis.Use(adminAuth)
apis.GET("", m.handler.list)
apis.POST("", m.handler.create)
apis.GET("/:id", m.handler.get)
apis.PUT("/:id", m.handler.update)
apis.DELETE("/:id", m.handler.delete)
apis.PUT("/:id/permissions", m.handler.updatePermissions)
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation)) {
httpapi.Register(api, huma.Operation{
OperationID: "list-apis",
Method: http.MethodGet,
Path: "/api/apis",
Summary: "List APIs",
Tags: []string{"APIs"},
}, m.handler.list, adminAuth)
// 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)
httpapi.Register(api, huma.Operation{
OperationID: "create-api",
Method: http.MethodPost,
Path: "/api/apis",
Summary: "Create API",
Tags: []string{"APIs"},
DefaultStatus: http.StatusCreated,
}, m.handler.create, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-api",
Method: http.MethodGet,
Path: "/api/apis/{id}",
Summary: "Get API by ID",
Tags: []string{"APIs"},
}, m.handler.get, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-api",
Method: http.MethodPut,
Path: "/api/apis/{id}",
Summary: "Update API",
Tags: []string{"APIs"},
}, m.handler.update, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-api",
Method: http.MethodDelete,
Path: "/api/apis/{id}",
Summary: "Delete API",
Tags: []string{"APIs"},
DefaultStatus: http.StatusNoContent,
}, m.handler.delete, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-api-permissions",
Method: http.MethodPut,
Path: "/api/apis/{id}/permissions",
Summary: "Update API permissions",
Tags: []string{"APIs"},
}, m.handler.updatePermissions, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-client-api-access",
Method: http.MethodGet,
Path: "/api/api-access/{clientId}",
Summary: "Get client API access",
Tags: []string{"APIs"},
}, m.handler.getClientAccess, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-client-api-access",
Method: http.MethodPut,
Path: "/api/api-access/{clientId}",
Summary: "Update client API access",
Tags: []string{"APIs"},
}, m.handler.updateClientAccess, adminAuth)
}

View File

@@ -56,8 +56,8 @@ func (s *Service) List(ctx context.Context, search string, listRequestOptions ut
Preload("Permissions").
Model(&API{})
if listRequestOptions.Sort.Column == "resource" {
listRequestOptions.Sort.Column = "audience"
if listRequestOptions.SortColumn == "resource" {
listRequestOptions.SortColumn = "audience"
}
if search != "" {

View File

@@ -5,13 +5,13 @@ import (
)
type apiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description *string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
Name string `json:"name" required:"true" minLength:"3" maxLength:"50" unorm:"nfc"`
Description *string `json:"description" required:"false" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"`
}
type apiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"`
}
type apiKeyDto struct {

View File

@@ -1,14 +1,25 @@
package apikey
import (
"net/http"
"github.com/gin-gonic/gin"
"context"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type createInput struct {
Body apiKeyCreateDto
}
type renewInput struct {
ID string `path:"id"`
Body apiKeyRenewDto
}
type idInput struct {
ID string `path:"id"`
}
type handler struct {
service *Service
}
@@ -17,123 +28,46 @@ func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiKeyDto]
// @Router /api/api-keys [get]
func (h *handler) list(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
func (h *handler) list(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[apiKeyDto]], error) {
apiKeys, pagination, err := h.service.ListApiKeys(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var apiKeysDto []apiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = c.Error(err)
return
var output []apiKeyDto
if err := dto.MapStructList(apiKeys, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[apiKeyDto]]{Body: dto.Paginated[apiKeyDto]{Data: output, Pagination: pagination}}, nil
}
// create godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body apiKeyCreateDto true "API key information"
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (h *handler) create(c *gin.Context) {
userID := c.GetString("userID")
var input apiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
apiKey, token, err := h.service.CreateApiKey(ctx, httpapi.UserID(ctx), input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
return mapAPIKeyResponse(apiKey, token)
}
// renew godoc
// @Summary Renew API key
// @Description Renew an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (h *handler) renew(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
var input apiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
func (h *handler) renew(ctx context.Context, input *renewInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
apiKey, token, err := h.service.RenewApiKey(ctx, httpapi.UserID(ctx), input.ID, input.Body.ExpiresAt.ToTime())
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
return mapAPIKeyResponse(apiKey, token)
}
// revoke godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (h *handler) revoke(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
_ = c.Error(err)
return
func (h *handler) revoke(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) {
if err := h.service.RevokeApiKey(ctx, httpapi.UserID(ctx), input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
func mapAPIKeyResponse(apiKey ApiKey, token string) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
var output apiKeyDto
if err := dto.MapStruct(apiKey, &output); err != nil {
return nil, err
}
return &httpapi.BodyOutput[apiKeyResponseDto]{Body: apiKeyResponseDto{ApiKey: output, Token: token}}, nil
}

View File

@@ -2,11 +2,13 @@ package apikey
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type Dependencies struct {
@@ -33,12 +35,40 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
// RegisterRoutes mounts the API key management endpoints
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
group := apiGroup.Group("/api-keys")
group.GET("", auth, m.handler.list)
group.POST("", authWithoutApiKey, m.handler.create)
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
group.DELETE("/:id", auth, m.handler.revoke)
func (m *Module) RegisterRoutes(api huma.API, auth, authWithoutAPIKey func(*huma.Operation)) {
httpapi.Register(api, huma.Operation{
OperationID: "list-api-keys",
Method: http.MethodGet,
Path: "/api/api-keys",
Summary: "List API keys",
Tags: []string{"API Keys"},
}, m.handler.list, auth)
httpapi.Register(api, huma.Operation{
OperationID: "create-api-key",
Method: http.MethodPost,
Path: "/api/api-keys",
Summary: "Create API key",
Tags: []string{"API Keys"},
DefaultStatus: http.StatusCreated,
}, m.handler.create, authWithoutAPIKey)
httpapi.Register(api, huma.Operation{
OperationID: "renew-api-key",
Method: http.MethodPost,
Path: "/api/api-keys/{id}/renew",
Summary: "Renew API key",
Tags: []string{"API Keys"},
}, m.handler.renew, authWithoutAPIKey)
httpapi.Register(api, huma.Operation{
OperationID: "revoke-api-key",
Method: http.MethodDelete,
Path: "/api/api-keys/{id}",
Summary: "Revoke API key",
Tags: []string{"API Keys"},
DefaultStatus: http.StatusNoContent,
}, m.handler.revoke, auth)
}
// ValidateApiKey resolves the user that owns the given raw API key

View File

@@ -6,7 +6,7 @@ import (
"log/slog"
"os"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/controller"
@@ -15,8 +15,8 @@ import (
// When building for E2E tests, add the e2etest controller
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
registerTestControllers = []func(api huma.API, db *gorm.DB, svc *services){
func(api huma.API, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
@@ -24,7 +24,7 @@ func init() {
return
}
controller.NewTestController(apiGroup, testService)
controller.NewTestController(api, testService)
},
}
}

View File

@@ -14,6 +14,7 @@ import (
"sync/atomic"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -27,11 +28,12 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
)
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
var registerTestControllers []func(api huma.API, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
r, err := initEngine()
@@ -143,50 +145,51 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
baseGroup := r.Group("/", rateLimitMiddleware.Add(middleware.RateLimitAPI))
apiGroup := baseGroup.Group("/api")
api := httpapi.New(r, baseGroup)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
baseGroup := r.Group("/", apiRateLimitMiddleware)
svc.apiKeyModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
svc.apiKeyModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api),
)
svc.webauthnModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
svc.webauthnModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
)
svc.deviceLoginModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginCreate),
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(api, authMiddleware, fileSizeLimitMiddleware, svc.appImagesService)
controller.NewAuditLogController(api, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(api, authMiddleware, svc.userGroupService)
svc.apiModule.RegisterRoutes(api, authMiddleware.Huma(api))
controller.NewCustomClaimController(api, authMiddleware, svc.customClaimService)
controller.NewVersionController(api, authMiddleware, svc.versionService)
controller.NewScimController(api, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(api,
authMiddleware.Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitSignup),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)
svc.oidcModule.RegisterTypedRoutes(api, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api))
registerTestRoutes(apiGroup, db, svc)
registerTestRoutes(api, db, svc)
controller.NewWellKnownController(baseGroup, svc.jwtService)
controller.NewWellKnownController(api, svc.jwtService)
// These are not rate-limited.
controller.NewHealthzController(r)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "healthz",
Method: http.MethodGet,
Path: "/healthz",
Summary: "Health check",
Tags: []string{"Health"},
}, http.StatusNoContent)
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
@@ -195,13 +198,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
return nil
}
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
func registerTestRoutes(api huma.API, db *gorm.DB, svc *services) {
if common.EnvConfig.AppEnv.IsProduction() {
return
}
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
f(api, db, svc)
}
}

View File

@@ -0,0 +1,109 @@
package bootstrap
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestHumaRouterOpenAPI(t *testing.T) {
originalConfig := common.EnvConfig
t.Cleanup(func() { common.EnvConfig = originalConfig })
common.EnvConfig.AppEnv = common.AppEnvTest
common.EnvConfig.AppURL = "https://test.example.com"
common.EnvConfig.InternalAppURL = "https://test.example.com"
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
common.EnvConfig.DisableRateLimiting = true
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
scheduler, err := job.NewScheduler()
require.NoError(t, err)
services, err := initServices(t.Context(), db, "test-instance", http.DefaultClient, map[string]string{}, fileStorage, scheduler)
require.NoError(t, err)
router, err := initEngine()
require.NoError(t, err)
require.NoError(t, registerRoutes(router, db, services, nil))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
require.Equal(t, http.StatusOK, response.Code)
var document struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
Responses map[string]any `json:"responses"`
} `json:"paths"`
Components struct {
SecuritySchemes map[string]any `json:"securitySchemes"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &document))
for _, path := range []string{
"/authorize",
"/.well-known/openid-configuration",
"/.well-known/jwks.json",
"/api/users",
"/api/user-groups",
"/api/oidc/token",
"/api/oidc/interactions/{id}",
"/api/webauthn/reauthenticate",
"/api/signup",
"/api/api-keys",
"/api/apis",
"/healthz",
} {
require.Contains(t, document.Paths, path)
}
operationIDs := map[string]struct{}{}
for _, methods := range document.Paths {
for _, operation := range methods {
require.NotEmpty(t, operation.OperationID)
require.NotContains(t, operation.Responses, "422")
_, duplicate := operationIDs[operation.OperationID]
require.False(t, duplicate, "duplicate operation ID %q", operation.OperationID)
operationIDs[operation.OperationID] = struct{}{}
}
}
for _, scheme := range []string{"BearerAuth", "SessionCookie", "ApiKeyAuth", "OIDCAccessToken", "OIDCClientBasic"} {
require.Contains(t, document.Components.SecuritySchemes, scheme)
}
require.NotContains(t, response.Body.String(), `"$schema"`)
require.NotContains(t, response.Header().Get("Link"), "schema")
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users", nil))
require.Equal(t, http.StatusUnauthorized, response.Code)
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
require.JSONEq(t, `{"error":"You are not signed in"}`, response.Body.String())
response = httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/signup/setup", nil)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
require.Equal(t, http.StatusOK, response.Code)
require.Contains(t, response.Header().Get("Content-Security-Policy"), "https://cdn.jsdelivr.net")
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
response = httptest.NewRecorder()
newHTTPServer(router, nil).Handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodHead, "/healthz", nil))
require.Equal(t, http.StatusNoContent, response.Code)
require.Empty(t, response.Body.String())
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/devicelogin"
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
@@ -37,12 +36,11 @@ type services struct {
appLockService *service.AppLockService
oneTimeAccessService *service.OneTimeAccessService
apiKeyModule *apikey.Module
deviceLoginModule *devicelogin.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
}
// Initializes all services
@@ -81,14 +79,6 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
}
svc.deviceLoginModule = devicelogin.New(devicelogin.Dependencies{
DB: db,
BaseURL: common.EnvConfig.AppURL,
Signer: svc.jwtService,
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogService,
AppConfig: svc.appConfigService,
})
svc.scimService = service.NewScimService(db, scheduler, httpClient)

View File

@@ -87,6 +87,14 @@ type NotSignedInError struct{}
func (e NotSignedInError) Error() string { return "You are not signed in" }
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e NotSignedInError) Is(target error) bool {
switch target.(type) {
case NotSignedInError, *NotSignedInError:
return true
default:
return false
}
}
type MissingPermissionError struct{}
@@ -181,20 +189,6 @@ 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" }

View File

@@ -1,40 +1,76 @@
package controller
import (
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewAppConfigController creates a new controller for application configuration endpoints
// @Summary Create a new application configuration controller
// @Description Initialize routes for application configuration
// @Tags Application Configuration
type appConfigUpdateInput struct {
Body dto.AppConfigUpdateDto
}
// NewAppConfigController registers application configuration endpoints
func NewAppConfigController(
group *gin.RouterGroup,
api huma.API,
authMiddleware *middleware.AuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
controller := &AppConfigController{appConfigService: appConfigService, emailService: emailService, ldapService: ldapService}
auth := authMiddleware.Huma(api)
acc := &AppConfigController{
appConfigService: appConfigService,
emailService: emailService,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
httpapi.Register(api, huma.Operation{
OperationID: "list-public-application-configuration",
Method: http.MethodGet,
Path: "/api/application-configuration",
Summary: "List public application configurations",
Tags: []string{"Application Configuration"},
}, controller.listAppConfigHandler)
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
httpapi.Register(api, huma.Operation{
OperationID: "list-all-application-configuration",
Method: http.MethodGet,
Path: "/api/application-configuration/all",
Summary: "List all application configurations",
Tags: []string{"Application Configuration"},
}, controller.listAllAppConfigHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-application-configuration",
Method: http.MethodPut,
Path: "/api/application-configuration",
Summary: "Update application configurations",
Tags: []string{"Application Configuration"},
}, controller.updateAppConfigHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "test-email-configuration",
Method: http.MethodPost,
Path: "/api/application-configuration/test-email",
Summary: "Send test email",
Tags: []string{"Application Configuration"},
DefaultStatus: http.StatusNoContent,
}, controller.testEmailHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "sync-ldap",
Method: http.MethodPost,
Path: "/api/application-configuration/sync-ldap",
Summary: "Synchronize LDAP",
Tags: []string{"Application Configuration"},
DefaultStatus: http.StatusNoContent,
}, controller.syncLDAPHandler, auth)
}
type AppConfigController struct {
@@ -43,121 +79,51 @@ type AppConfigController struct {
ldapService *service.LdapService
}
// listAppConfigHandler godoc
// @Summary List public application configurations
// @Description Get all public application configurations
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Router /api/application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto], error) {
configuration := acc.appConfigService.ListAppConfig(false)
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
_ = c.Error(err)
return
var output []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &output); err != nil {
return nil, err
}
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "uiConfigDisabled",
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
Type: "boolean",
})
// Manually add tracingEnabled, derived from the OTel environment, so the frontend only exports traces when the backend can forward them to a collector
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "tracingEnabled",
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto)
output = append(output,
dto.PublicAppConfigVariableDto{Key: "uiConfigDisabled", Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled), Type: "boolean"},
dto.PublicAppConfigVariableDto{Key: "tracingEnabled", Value: strconv.FormatBool(tracing.FrontendTracingEnabled()), Type: "boolean"},
)
return &httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto]{Body: output}, nil
}
// listAllAppConfigHandler godoc
// @Summary List all application configurations
// @Description Get all application configurations including private ones
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAllAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
configuration := acc.appConfigService.ListAppConfig(true)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
_ = c.Error(err)
return
var output []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, configVariablesDto)
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
}
// updateAppConfigHandler godoc
// @Summary Update application configurations
// @Description Update application configuration settings
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
func (acc *AppConfigController) updateAppConfigHandler(ctx context.Context, input *appConfigUpdateInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
saved, err := acc.appConfigService.UpdateAppConfig(ctx, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
_ = c.Error(err)
return
var output []dto.AppConfigVariableDto
if err := dto.MapStructList(saved, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, configVariablesDto)
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
}
// syncLdapHandler godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
func (acc *AppConfigController) syncLDAPHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
if err := acc.ldapService.SyncAll(ctx); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// testEmailHandler godoc
// @Summary Send test email
// @Description Send a test email to verify email configuration
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
func (acc *AppConfigController) testEmailHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
if err := acc.emailService.SendTestEmail(ctx, httpapi.UserID(ctx)); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}

View File

@@ -1,292 +1,287 @@
package controller
import (
"context"
"io"
"mime/multipart"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
func NewAppImagesController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appImagesService *service.AppImagesService,
) {
controller := &AppImagesController{
appImagesService: appImagesService,
}
type imageGetInput struct {
Light string `query:"light" default:"true" required:"false"`
}
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)
type imageUploadForm struct {
File huma.FormFile `form:"file" required:"true"`
}
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)
type imageUploadInput struct {
RawBody huma.MultipartFormFiles[imageUploadForm]
}
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
type logoUploadInput struct {
Light string `query:"light" default:"true" required:"false"`
RawBody huma.MultipartFormFiles[imageUploadForm]
}
type imageOutput struct {
ContentType string `header:"Content-Type"`
ContentLength int64 `header:"Content-Length"`
CacheControl string `header:"Cache-Control"`
Body func(huma.Context)
}
func NewAppImagesController(api huma.API, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, appImagesService *service.AppImagesService) {
controller := &AppImagesController{appImagesService: appImagesService}
auth := authMiddleware.Huma(api)
uploadLimit := httpapi.WithMiddleware(fileSizeLimitMiddleware.Huma(api, 2<<20))
httpapi.Register(api, huma.Operation{
OperationID: "get-application-logo",
Method: http.MethodGet,
Path: "/api/application-images/logo",
Summary: "Get logo image",
Tags: []string{"Application Images"},
}, controller.getLogoHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-email-logo",
Method: http.MethodGet,
Path: "/api/application-images/email",
Summary: "Get email logo image",
Tags: []string{"Application Images"},
}, controller.getEmailLogoHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-background-image",
Method: http.MethodGet,
Path: "/api/application-images/background",
Summary: "Get background image",
Tags: []string{"Application Images"},
}, controller.getBackgroundImageHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-favicon",
Method: http.MethodGet,
Path: "/api/application-images/favicon",
Summary: "Get favicon",
Tags: []string{"Application Images"},
}, controller.getFaviconHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-default-profile-picture",
Method: http.MethodGet,
Path: "/api/application-images/default-profile-picture",
Summary: "Get default profile picture",
Tags: []string{"Application Images"},
}, controller.getDefaultProfilePicture, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-application-logo",
Method: http.MethodPut,
Path: "/api/application-images/logo",
Summary: "Update logo",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.updateLogoHandler, auth, uploadLimit)
httpapi.Register(api, huma.Operation{
OperationID: "update-email-logo",
Method: http.MethodPut,
Path: "/api/application-images/email",
Summary: "Update email logo",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.updateEmailLogoHandler, auth, uploadLimit)
httpapi.Register(api, huma.Operation{
OperationID: "update-background-image",
Method: http.MethodPut,
Path: "/api/application-images/background",
Summary: "Update background image",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.updateBackgroundImageHandler, auth, uploadLimit)
httpapi.Register(api, huma.Operation{
OperationID: "update-favicon",
Method: http.MethodPut,
Path: "/api/application-images/favicon",
Summary: "Update favicon",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.updateFaviconHandler, auth, uploadLimit)
httpapi.Register(api, huma.Operation{
OperationID: "update-default-profile-picture",
Method: http.MethodPut,
Path: "/api/application-images/default-profile-picture",
Summary: "Update default profile picture",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.updateDefaultProfilePicture, auth, uploadLimit)
httpapi.Register(api, huma.Operation{
OperationID: "delete-background-image",
Method: http.MethodDelete,
Path: "/api/application-images/background",
Summary: "Delete background image",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.deleteBackgroundImageHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-default-profile-picture",
Method: http.MethodDelete,
Path: "/api/application-images/default-profile-picture",
Summary: "Delete default profile picture",
Tags: []string{"Application Images"},
DefaultStatus: http.StatusNoContent,
}, controller.deleteDefaultProfilePicture, auth)
}
type AppImagesController struct {
appImagesService *service.AppImagesService
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Images
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
func (c *AppImagesController) getLogoHandler(ctx context.Context, input *imageGetInput) (*imageOutput, error) {
lightLogo, _ := strconv.ParseBool(input.Light)
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
c.getImage(ctx, imageName)
return c.getImage(ctx, imageName)
}
// getEmailLogoHandler godoc
// @Summary Get email logo image
// @Description Get the email logo image for use in emails
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Email logo image"
// @Router /api/application-images/email [get]
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
c.getImage(ctx, "logoEmail")
func (c *AppImagesController) getEmailLogoHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "logoEmail")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-images/background [get]
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
c.getImage(ctx, "background")
func (c *AppImagesController) getBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "background")
}
// getFaviconHandler godoc
// @Summary Get favicon
// @Description Get the favicon for the application
// @Tags Application Images
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-images/favicon [get]
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon")
func (c *AppImagesController) getFaviconHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "favicon")
}
// getDefaultProfilePicture godoc
// @Summary Get default profile picture image
// @Description Get the default profile picture image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
func (c *AppImagesController) getDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc
// @Summary Update logo
// @Description Update the application logo
// @Tags Application Images
// @Accept multipart/form-data
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/logo [put]
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateLogoHandler(ctx context.Context, input *logoUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
lightLogo, _ := strconv.ParseBool(input.Light)
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err)
return
if err := c.appImagesService.UpdateImage(ctx, file, imageName); err != nil {
return nil, err
}
ctx.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Email logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/email [put]
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateEmailLogoHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
mimeType := utils.GetImageMimeType(utils.GetFileExtension(file.Filename))
if mimeType != "image/png" && mimeType != "image/jpeg" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
return nil, &common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
return c.updateImage(ctx, file, "logoEmail")
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-images/background [put]
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateBackgroundImageHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
return c.updateImage(ctx, file, "background")
}
// deleteBackgroundImageHandler godoc
// @Summary Delete background image
// @Description Delete the application background image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/background [delete]
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateFaviconHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
mimeType := utils.GetImageMimeType(strings.ToLower(utils.GetFileExtension(file.Filename)))
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
return
return nil, &common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"}
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
return c.updateImage(ctx, file, "favicon")
}
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
func (c *AppImagesController) updateDefaultProfilePicture(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
defer reader.Close()
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
return c.updateImage(ctx, file, "default-profile-picture")
}
// updateDefaultProfilePicture godoc
// @Summary Update default profile picture image
// @Description Update the default profile picture image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
func (c *AppImagesController) updateImage(ctx context.Context, file *multipart.FileHeader, name string) (*httpapi.EmptyOutput, error) {
if err := c.appImagesService.UpdateImage(ctx, file, name); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
func uploadFile(form *multipart.Form) (*multipart.FileHeader, error) {
files := form.File["file"]
if len(files) == 0 {
return nil, http.ErrMissingFile
}
return files[0], nil
}
func (c *AppImagesController) deleteBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
if err := c.appImagesService.DeleteImage(ctx, "background"); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
func (c *AppImagesController) deleteDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
if err := c.appImagesService.DeleteImage(ctx, "default-profile-picture"); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
func (c *AppImagesController) getImage(ctx context.Context, name string) (*imageOutput, error) {
reader, size, mimeType, err := c.appImagesService.GetImage(ctx, name)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
cacheControl := ""
if !httpapi.QueryPresent(ctx, "skipCache") {
cacheControl = utils.CacheControlValue(15*time.Minute, 24*time.Hour)
}
ctx.Status(http.StatusNoContent)
}
// deleteDefaultProfilePicture godoc
// @Summary Delete default profile picture image
// @Description Delete the default profile picture image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
return &imageOutput{
ContentType: mimeType,
ContentLength: size,
CacheControl: cacheControl,
Body: func(streamCtx huma.Context) {
defer reader.Close()
_, _ = io.Copy(streamCtx.BodyWriter(), reader)
},
}, nil
}

View File

@@ -1,145 +1,114 @@
package controller
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewAuditLogController creates a new controller for audit log management
// @Summary Audit log controller
// @Description Initializes API endpoints for accessing audit logs
// @Tags Audit Logs
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}
// NewAuditLogController registers audit log routes
func NewAuditLogController(api huma.API, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
controller := &AuditLogController{auditLogService: auditLogService}
adminAuth := authMiddleware.Huma(api)
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
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)
httpapi.Register(api, huma.Operation{
OperationID: "list-all-audit-logs",
Method: http.MethodGet,
Path: "/api/audit-logs/all",
Summary: "List all audit logs",
Tags: []string{"Audit Logs"},
}, controller.listAllAuditLogsHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-current-user-audit-logs",
Method: http.MethodGet,
Path: "/api/audit-logs",
Summary: "List audit logs for the current user",
Tags: []string{"Audit Logs"},
}, controller.listAuditLogsForUserHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-audit-log-client-names",
Method: http.MethodGet,
Path: "/api/audit-logs/filters/client-names",
Summary: "List client names",
Tags: []string{"Audit Logs"},
}, controller.listClientNamesHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-audit-log-users",
Method: http.MethodGet,
Path: "/api/audit-logs/filters/users",
Summary: "List users with IDs",
Tags: []string{"Audit Logs"},
}, controller.listUserNamesWithIDsHandler, adminAuth)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
// listAuditLogsForUserHandler godoc
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
// @Tags Audit Logs
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
func (alc *AuditLogController) listAuditLogsForUserHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) {
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
logsDTOs, err := alc.mapAuditLogs(logs, false)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
}
// listAllAuditLogsHandler godoc
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
func (alc *AuditLogController) listAllAuditLogsHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) {
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(ctx, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
logsDTOs, err := alc.mapAuditLogs(logs, true)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
}
// listClientNamesHandler godoc
// @Summary List client names
// @Description Get a list of all client names for audit log filtering
// @Tags Audit Logs
// @Success 200 {array} string "List of client names"
// @Router /api/audit-logs/filters/client-names [get]
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
func (alc *AuditLogController) mapAuditLogs(logs []model.AuditLog, includeUsername bool) ([]dto.AuditLogDto, error) {
var logsDTOs []dto.AuditLogDto
if err := dto.MapStructList(logs, &logsDTOs); err != nil {
return nil, err
}
c.JSON(http.StatusOK, names)
for i := range logsDTOs {
logsDTOs[i].Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDTOs[i].ActorUsername = logsDTOs[i].Data["actorUsername"]
if includeUsername {
logsDTOs[i].Username = logs[i].User.Username
}
}
return logsDTOs, nil
}
// listUserNamesWithIdsHandler godoc
// @Summary List users with IDs
// @Description Get a list of all usernames with their IDs for audit log filtering
// @Tags Audit Logs
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
// @Router /api/audit-logs/filters/users [get]
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
func (alc *AuditLogController) listClientNamesHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
names, err := alc.auditLogService.ListClientNames(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, users)
return &httpapi.BodyOutput[[]string]{Body: names}, nil
}
func (alc *AuditLogController) listUserNamesWithIDsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) {
users, err := alc.auditLogService.ListUsernamesWithIds(ctx)
if err != nil {
return nil, err
}
return &httpapi.BodyOutput[map[string]string]{Body: users}, nil
}

View File

@@ -1,115 +1,91 @@
package controller
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewCustomClaimController creates a new controller for custom claim management
// @Summary Custom claim management controller
// @Description Initializes all custom claim-related API endpoints
// @Tags Custom Claims
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
type customClaimUserInput struct {
UserID string `path:"userId"`
Body []dto.CustomClaimCreateDto `required:"true"`
}
customClaimsGroup := group.Group("/custom-claims")
customClaimsGroup.Use(authMiddleware.Add())
{
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
}
type customClaimUserGroupInput struct {
UserGroupID string `path:"userGroupId"`
Body []dto.CustomClaimCreateDto `required:"true"`
}
// NewCustomClaimController registers custom claim management routes
func NewCustomClaimController(api huma.API, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
controller := &CustomClaimController{customClaimService: customClaimService}
auth := authMiddleware.Huma(api)
httpapi.Register(api, huma.Operation{
OperationID: "list-custom-claim-suggestions",
Method: http.MethodGet,
Path: "/api/custom-claims/suggestions",
Summary: "Get custom claim suggestions",
Tags: []string{"Custom Claims"},
}, controller.getSuggestionsHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-user-custom-claims",
Method: http.MethodPut,
Path: "/api/custom-claims/user/{userId}",
Summary: "Update custom claims for a user",
Tags: []string{"Custom Claims"},
}, controller.updateCustomClaimsForUserHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-user-group-custom-claims",
Method: http.MethodPut,
Path: "/api/custom-claims/user-group/{userGroupId}",
Summary: "Update custom claims for a user group",
Tags: []string{"Custom Claims"},
}, controller.updateCustomClaimsForUserGroupHandler, auth)
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
// getSuggestionsHandler godoc
// @Summary Get custom claim suggestions
// @Description Get a list of suggested custom claim names
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
func (ccc *CustomClaimController) getSuggestionsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
claims, err := ccc.customClaimService.GetSuggestions(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, claims)
return &httpapi.BodyOutput[[]string]{Body: claims}, nil
}
// UpdateCustomClaimsForUserHandler godoc
// @Summary Update custom claims for a user
// @Description Update or create custom claims for a specific user
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
func (ccc *CustomClaimController) updateCustomClaimsForUserHandler(ctx context.Context, input *customClaimUserInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) {
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(ctx, input.UserID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
var output []dto.CustomClaimDto
if err := dto.MapStructList(claims, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, customClaimsDto)
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
}
// UpdateCustomClaimsForUserGroupHandler godoc
// @Summary Update custom claims for a user group
// @Description Update or create custom claims for a specific user group
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
func (ccc *CustomClaimController) updateCustomClaimsForUserGroupHandler(ctx context.Context, input *customClaimUserGroupInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) {
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(ctx, input.UserGroupID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
var output []dto.CustomClaimDto
if err := dto.MapStructList(claims, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, customClaimsDto)
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
}

View File

@@ -3,150 +3,167 @@
package controller
import (
"context"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
testController := &TestController{TestService: testService}
type testResetInput struct {
SkipLDAP string `query:"skip-ldap" required:"false"`
SkipSeed string `query:"skip-seed" required:"false"`
}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/accesstoken", testController.signAccessToken)
group.POST("/test/refreshtoken", testController.signRefreshToken)
type testExternalIDPInput struct {
Body struct {
Audience string `json:"aud" required:"true"`
Issuer string `json:"iss" required:"true"`
Subject string `json:"sub" required:"true"`
}
}
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
group.POST("/externalidp/sign", testController.externalIdPSignToken)
type testAccessTokenInput struct {
Body struct {
UserID string `json:"user" required:"true"`
ClientID string `json:"client" required:"true"`
Expired bool `json:"expired" required:"false"`
}
}
type testRefreshTokenInput struct {
Body struct {
UserID string `json:"user" required:"true"`
ClientID string `json:"client" required:"true"`
RefreshToken string `json:"rt" required:"true"`
}
}
type testBytesOutput struct {
ContentType string `header:"Content-Type"`
Body []byte
}
func NewTestController(api huma.API, testService *service.TestService) {
controller := &TestController{TestService: testService}
httpapi.Register(api, huma.Operation{
OperationID: "test-reset",
Method: http.MethodPost,
Path: "/api/test/reset",
Tags: []string{"E2E Test"},
Hidden: true,
DefaultStatus: http.StatusNoContent,
}, controller.resetAndSeedHandler)
httpapi.Register(api, huma.Operation{
OperationID: "test-sign-access-token",
Method: http.MethodPost,
Path: "/api/test/accesstoken",
Tags: []string{"E2E Test"},
Hidden: true,
}, controller.signAccessToken)
httpapi.Register(api, huma.Operation{
OperationID: "test-sign-refresh-token",
Method: http.MethodPost,
Path: "/api/test/refreshtoken",
Tags: []string{"E2E Test"},
Hidden: true,
}, controller.signRefreshToken)
httpapi.Register(api, huma.Operation{
OperationID: "test-external-idp-jwks",
Method: http.MethodGet,
Path: "/api/externalidp/jwks.json",
Tags: []string{"E2E Test"},
Hidden: true,
}, controller.externalIDPJWKS)
httpapi.Register(api, huma.Operation{
OperationID: "test-external-idp-sign",
Method: http.MethodPost,
Path: "/api/externalidp/sign",
Tags: []string{"E2E Test"},
Hidden: true,
}, controller.externalIDPSignToken)
}
type TestController struct {
TestService *service.TestService
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
var baseURL string
if c.Request.TLS != nil {
baseURL = "https://" + c.Request.Host
} else {
baseURL = "http://" + c.Request.Host
func (tc *TestController) resetAndSeedHandler(ctx context.Context, input *testResetInput) (*httpapi.EmptyOutput, error) {
request := httpapi.Request(ctx)
scheme := "http"
if request.TLS != nil {
scheme = "https"
}
skipLdap := c.Query("skip-ldap") == "true"
skipSeed := c.Query("skip-seed") == "true"
baseURL := scheme + "://" + request.Host
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
return
return nil, err
}
if err := tc.TestService.ResetLock(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.ResetLock(ctx); err != nil {
return nil, err
}
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.ResetApplicationImages(ctx); err != nil {
return nil, err
}
if !skipSeed {
if input.SkipSeed != "true" {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
return nil, err
}
}
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.ResetAppConfig(ctx); err != nil {
return nil, err
}
if !skipLdap {
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if input.SkipLDAP != "true" {
if err := tc.TestService.SetLdapTestConfig(ctx); err != nil {
return nil, err
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.SyncLdap(ctx); err != nil {
return nil, err
}
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
func (tc *TestController) externalIDPJWKS(_ context.Context, _ *httpapi.EmptyInput) (*testBytesOutput, error) {
jwks, err := tc.TestService.GetExternalIdPJWKS()
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, jwks)
body, err := json.Marshal(jwks)
if err != nil {
return nil, err
}
return &testBytesOutput{ContentType: "application/json; charset=utf-8", Body: body}, nil
}
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
var input struct {
Aud string `json:"aud"`
Iss string `json:"iss"`
Sub string `json:"sub"`
}
err := c.ShouldBindJSON(&input)
func (tc *TestController) externalIDPSignToken(_ context.Context, input *testExternalIDPInput) (*testBytesOutput, error) {
token, err := tc.TestService.SignExternalIdPToken(input.Body.Issuer, input.Body.Subject, input.Body.Audience)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
}
func (tc *TestController) signAccessToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
Expired bool `json:"expired"`
}
err := c.ShouldBindJSON(&input)
func (tc *TestController) signAccessToken(ctx context.Context, input *testAccessTokenInput) (*testBytesOutput, error) {
token, err := tc.TestService.SignAccessToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.Expired)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
RefreshToken string `json:"rt"`
}
err := c.ShouldBindJSON(&input)
func (tc *TestController) signRefreshToken(ctx context.Context, input *testRefreshTokenInput) (*testBytesOutput, error) {
token, err := tc.TestService.SignRefreshToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.RefreshToken)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
}

View File

@@ -1,538 +1,435 @@
package controller
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewOidcController creates a new controller for OIDC related endpoints
// @Summary OIDC controller
// @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
oc := &OidcController{
oidcService: oidcService,
}
type oidcClientIDInput struct {
ID string `path:"id"`
}
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
type oidcClientListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
}
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
type oidcClientCreateInput struct {
Body dto.OidcClientCreateDto
}
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
type oidcClientUpdateInput struct {
ID string `path:"id"`
Body dto.OidcClientUpdateDto
}
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
type oidcAllowedGroupsInput struct {
ID string `path:"id"`
Body dto.OidcUpdateAllowedUserGroupsDto
}
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
type oidcLogoInput struct {
ID string `path:"id"`
Light string `query:"light" default:"true" required:"false"`
}
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
type oidcLogoUploadInput struct {
ID string `path:"id"`
Light string `query:"light" default:"true" required:"false"`
RawBody huma.MultipartFormFiles[imageUploadForm]
}
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
type oidcUserAuthorizedClientsInput struct {
utils.ListRequestOptions
ID string `path:"id"`
}
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
type oidcOwnAuthorizedClientsInput struct {
utils.ListRequestOptions
}
type oidcClientAuthorizationInput struct {
ClientID string `path:"clientId"`
}
type oidcPreviewInput struct {
ID string `path:"id"`
UserID string `path:"userId"`
Scopes string `query:"scopes" required:"true"`
}
type oidcLogoOutput struct {
ContentType string `header:"Content-Type"`
ContentLength int64 `header:"Content-Length"`
CacheControl string `header:"Cache-Control"`
Body func(huma.Context)
}
// NewOidcController registers typed OIDC client management endpoints
func NewOidcController(api huma.API, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
controller := &OidcController{oidcService: oidcService}
adminAuth := authMiddleware.Huma(api)
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
httpapi.Register(api, huma.Operation{
OperationID: "list-oidc-clients",
Method: http.MethodGet,
Path: "/api/oidc/clients",
Summary: "List OIDC clients",
Tags: []string{"OIDC"},
}, controller.listClientsHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "create-oidc-client",
Method: http.MethodPost,
Path: "/api/oidc/clients",
Summary: "Create OIDC client",
Tags: []string{"OIDC"},
DefaultStatus: http.StatusCreated,
}, controller.createClientHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-client",
Method: http.MethodGet,
Path: "/api/oidc/clients/{id}",
Summary: "Get OIDC client",
Tags: []string{"OIDC"},
}, controller.getClientHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-client-metadata",
Method: http.MethodGet,
Path: "/api/oidc/clients/{id}/meta",
Summary: "Get OIDC client metadata",
Tags: []string{"OIDC"},
}, controller.getClientMetaDataHandler)
httpapi.Register(api, huma.Operation{
OperationID: "update-oidc-client",
Method: http.MethodPut,
Path: "/api/oidc/clients/{id}",
Summary: "Update OIDC client",
Tags: []string{"OIDC"},
}, controller.updateClientHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-oidc-client",
Method: http.MethodDelete,
Path: "/api/oidc/clients/{id}",
Summary: "Delete OIDC client",
Tags: []string{"OIDC"},
DefaultStatus: http.StatusNoContent,
}, controller.deleteClientHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-oidc-client-allowed-user-groups",
Method: http.MethodPut,
Path: "/api/oidc/clients/{id}/allowed-user-groups",
Summary: "Update allowed user groups",
Tags: []string{"OIDC"},
}, controller.updateAllowedUserGroupsHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "create-oidc-client-secret",
Method: http.MethodPost,
Path: "/api/oidc/clients/{id}/secret",
Summary: "Create client secret",
Tags: []string{"OIDC"},
}, controller.createClientSecretHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-client-logo",
Method: http.MethodGet,
Path: "/api/oidc/clients/{id}/logo",
Summary: "Get client logo",
Tags: []string{"OIDC"},
}, controller.getClientLogoHandler)
httpapi.Register(api, huma.Operation{
OperationID: "delete-oidc-client-logo",
Method: http.MethodDelete,
Path: "/api/oidc/clients/{id}/logo",
Summary: "Delete client logo",
Tags: []string{"OIDC"},
DefaultStatus: http.StatusNoContent,
}, controller.deleteClientLogoHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-oidc-client-logo",
Method: http.MethodPost,
Path: "/api/oidc/clients/{id}/logo",
Summary: "Update client logo",
Tags: []string{"OIDC"},
DefaultStatus: http.StatusNoContent,
}, controller.updateClientLogoHandler, adminAuth, httpapi.WithMiddleware(fileSizeLimitMiddleware.Huma(api, 2<<20)))
httpapi.Register(api, huma.Operation{
OperationID: "preview-oidc-client-data",
Method: http.MethodGet,
Path: "/api/oidc/clients/{id}/preview/{userId}",
Summary: "Preview OIDC client data for user",
Tags: []string{"OIDC"},
}, controller.getClientPreviewHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-own-authorized-oidc-clients",
Method: http.MethodGet,
Path: "/api/oidc/users/me/authorized-clients",
Summary: "List authorized clients for current user",
Tags: []string{"OIDC"},
}, controller.listOwnAuthorizedClientsHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-user-authorized-oidc-clients",
Method: http.MethodGet,
Path: "/api/oidc/users/{id}/authorized-clients",
Summary: "List authorized clients for a user",
Tags: []string{"OIDC"},
}, controller.listAuthorizedClientsHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "revoke-own-oidc-client-authorization",
Method: http.MethodDelete,
Path: "/api/oidc/users/me/authorized-clients/{clientId}",
Summary: "Revoke authorization for an OIDC client",
Tags: []string{"OIDC"},
DefaultStatus: http.StatusNoContent,
}, controller.revokeOwnClientAuthorizationHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-own-accessible-oidc-clients",
Method: http.MethodGet,
Path: "/api/oidc/users/me/clients",
Summary: "List accessible OIDC clients for current user",
Tags: []string{"OIDC"},
}, controller.listOwnAccessibleClientsHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-client-scim-service-provider",
Method: http.MethodGet,
Path: "/api/oidc/clients/{id}/scim-service-provider",
Summary: "Get SCIM service provider",
Tags: []string{"OIDC"},
}, controller.getClientScimServiceProviderHandler, adminAuth)
}
type OidcController struct {
oidcService *service.OidcService
}
// getClientMetaDataHandler godoc
// @Summary Get client metadata
// @Description Get OIDC client metadata for discovery and configuration
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
// @Router /api/oidc/clients/{id}/meta [get]
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
func (oc *OidcController) getClientMetaDataHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientMetaDataDto], error) {
client, err := oc.oidcService.GetClient(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return
var output dto.OidcClientMetaDataDto
if err := dto.MapStruct(client, &output); err != nil {
return nil, err
}
_ = c.Error(err)
output.HasDarkLogo = client.HasDarkLogo()
return &httpapi.BodyOutput[dto.OidcClientMetaDataDto]{Body: output}, nil
}
// getClientHandler godoc
// @Summary Get OIDC client
// @Description Get detailed information about an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
func (oc *OidcController) getClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
client, err := oc.oidcService.GetClient(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
return mapOIDCClient(client)
}
// listClientsHandler godoc
// @Summary List OIDC clients
// @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
func (oc *OidcController) listClientsHandler(ctx context.Context, input *oidcClientListInput) (*httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]], error) {
clients, pagination, err := oc.oidcService.ListClients(ctx, input.Search, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
output := make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i := range clients {
if err := dto.MapStruct(clients[i], &output[i]); err != nil {
return nil, err
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
output[i].HasDarkLogo = clients[i].HasDarkLogo()
output[i].AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(ctx, clients[i].ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]]{Body: dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{Data: output, Pagination: pagination}}, nil
}
// createClientHandler godoc
// @Summary Create OIDC client
// @Description Create a new OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
func (oc *OidcController) createClientHandler(ctx context.Context, input *oidcClientCreateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
client, err := oc.oidcService.CreateClient(ctx, input.Body, httpapi.UserID(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
return mapOIDCClient(client)
}
// deleteClientHandler godoc
// @Summary Delete OIDC client
// @Description Delete an OIDC client by ID
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
func (oc *OidcController) deleteClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.EmptyOutput, error) {
if err := oc.oidcService.DeleteClient(ctx, input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// updateClientHandler godoc
// @Summary Update OIDC client
// @Description Update an existing OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientUpdateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcClientUpdateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
client, err := oc.oidcService.UpdateClient(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
return mapOIDCClient(client)
}
// createClientSecretHandler godoc
// @Summary Create client secret
// @Description Generate a new secret for an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) {
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, gin.H{"secret": secret})
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"secret": secret}}, nil
}
// getClientLogoHandler godoc
// @Summary Get client logo
// @Description Get the logo image for an OIDC client
// @Tags OIDC
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
func (oc *OidcController) getClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*oidcLogoOutput, error) {
light, _ := strconv.ParseBool(input.Light)
reader, size, mimeType, err := oc.oidcService.GetClientLogo(ctx, input.ID, light)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
defer reader.Close()
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
cacheControl := ""
if !httpapi.QueryPresent(ctx, "skipCache") {
cacheControl = utils.CacheControlValue(15*time.Minute, 12*time.Hour)
}
return &oidcLogoOutput{
ContentType: mimeType,
ContentLength: size,
CacheControl: cacheControl,
Body: func(streamCtx huma.Context) {
defer reader.Close()
_, _ = io.Copy(streamCtx.BodyWriter(), reader)
},
}, nil
}
// updateClientLogoHandler godoc
// @Summary Update client logo
// @Description Upload or update the logo for an OIDC client
// @Tags OIDC
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
func (oc *OidcController) updateClientLogoHandler(ctx context.Context, input *oidcLogoUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
if err != nil {
_ = c.Error(err)
return
light, _ := strconv.ParseBool(input.Light)
if err := oc.oidcService.UpdateClientLogo(ctx, input.ID, file, light); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// deleteClientLogoHandler godoc
// @Summary Delete client logo
// @Description Delete the logo for an OIDC client
// @Tags OIDC
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*httpapi.EmptyOutput, error) {
light, _ := strconv.ParseBool(input.Light)
var err error
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
if lightLogo {
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
if light {
err = oc.oidcService.DeleteClientLogo(ctx, input.ID)
} else {
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
err = oc.oidcService.DeleteClientDarkLogo(ctx, input.ID)
}
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// updateAllowedUserGroupsHandler godoc
// @Summary Update allowed user groups
// @Description Update the user groups allowed to access an OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
func (oc *OidcController) updateAllowedUserGroupsHandler(ctx context.Context, input *oidcAllowedGroupsInput) (*httpapi.BodyOutput[dto.OidcClientDto], error) {
client, err := oc.oidcService.UpdateAllowedUserGroups(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
_ = c.Error(err)
return
var output dto.OidcClientDto
if err := dto.MapStruct(client, &output); err != nil {
return nil, err
}
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
c.JSON(http.StatusOK, oidcClientDto)
output.HasDarkLogo = client.HasDarkLogo()
return &httpapi.BodyOutput[dto.OidcClientDto]{Body: output}, nil
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
func (oc *OidcController) listOwnAuthorizedClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
return oc.listAuthorizedClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
}
// listAuthorizedClientsHandler godoc
// @Summary List authorized clients for a user
// @Description Get a paginated list of OIDC clients that a specific user has authorized
// @Tags OIDC
// @Param id path string true "User ID"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
func (oc *OidcController) listAuthorizedClientsHandler(ctx context.Context, input *oidcUserAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
return oc.listAuthorizedClients(ctx, input.ID, input.ListRequestOptions)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
func (oc *OidcController) listAuthorizedClients(ctx context.Context, userID string, options utils.ListRequestOptions) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
clients, pagination, err := oc.oidcService.ListAuthorizedClients(ctx, userID, options)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
var output []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(clients, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]]{Body: dto.Paginated[dto.AuthorizedOidcClientDto]{Data: output, Pagination: pagination}}, nil
}
// revokeOwnClientAuthorizationHandler godoc
// @Summary Revoke authorization for an OIDC client
// @Description Revoke the authorization for a specific OIDC client for the current user
// @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId")
func (oc *OidcController) revokeOwnClientAuthorizationHandler(ctx context.Context, input *oidcClientAuthorizationInput) (*httpapi.EmptyOutput, error) {
if err := oc.oidcService.RevokeAuthorizedClient(ctx, httpapi.UserID(ctx), input.ClientID); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
userID := c.GetString("userID")
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
func (oc *OidcController) listOwnAccessibleClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]], error) {
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]]{Body: dto.Paginated[dto.AccessibleOidcClientDto]{Data: clients, Pagination: pagination}}, nil
}
// listOwnAccessibleClientsHandler godoc
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
func (oc *OidcController) getClientPreviewHandler(ctx context.Context, input *oidcPreviewInput) (*httpapi.BodyOutput[dto.OidcClientPreviewDto], error) {
if input.ID == "" {
return nil, &common.ValidationError{Message: "client ID is required"}
}
if input.UserID == "" {
return nil, &common.ValidationError{Message: "user ID is required"}
}
if input.Scopes == "" {
return nil, &common.ValidationError{Message: "scopes are required"}
}
preview, err := oc.oidcService.GetClientPreview(ctx, input.ID, input.UserID, strings.Split(input.Scopes, " "), httpapi.AuthenticationMethod(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.OidcClientPreviewDto]{Body: *preview}, nil
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Param userId path string true "User ID to preview data for"
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
clientID := c.Param("id")
userID := c.Param("userId")
scopes := c.Query("scopes")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
return
}
if userID == "" {
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
return
}
if scopes == "" {
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
return
}
preview, err := oc.oidcService.GetClientPreview(
c.Request.Context(),
clientID,
userID,
strings.Split(scopes, " "),
c.GetString("authenticationMethod"))
func (oc *OidcController) getClientScimServiceProviderHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := oc.oidcService.GetClientScimServiceProvider(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, preview)
var output dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &output); err != nil {
return nil, err
}
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
}
// getClientScimServiceProviderHandler godoc
// @Summary Get SCIM service provider
// @Description Get the SCIM service provider configuration for an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
clientID := c.Param("id")
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
if err != nil {
_ = c.Error(err)
return
func mapOIDCClient(client model.OidcClient) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
var output dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &output); err != nil {
return nil, err
}
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, providerDto)
return &httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto]{Body: output}, nil
}

View File

@@ -1,122 +1,108 @@
package controller
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
ugc := ScimController{
scimService: scimService,
}
type scimIDInput struct {
ID string `path:"id"`
}
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
type scimCreateInput struct {
Body dto.ScimServiceProviderCreateDTO
}
type scimUpdateInput struct {
ID string `path:"id"`
Body dto.ScimServiceProviderCreateDTO
}
func NewScimController(api huma.API, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
controller := &ScimController{scimService: scimService}
auth := authMiddleware.Huma(api)
httpapi.Register(api, huma.Operation{
OperationID: "create-scim-service-provider",
Method: http.MethodPost,
Path: "/api/scim/service-provider",
Summary: "Create SCIM service provider",
Tags: []string{"SCIM"},
DefaultStatus: http.StatusCreated,
}, controller.createServiceProviderHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "sync-scim-service-provider",
Method: http.MethodPost,
Path: "/api/scim/service-provider/{id}/sync",
Summary: "Sync SCIM service provider",
Tags: []string{"SCIM"},
DefaultStatus: http.StatusOK,
}, controller.syncServiceProviderHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-scim-service-provider",
Method: http.MethodPut,
Path: "/api/scim/service-provider/{id}",
Summary: "Update SCIM service provider",
Tags: []string{"SCIM"},
}, controller.updateServiceProviderHandler, auth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-scim-service-provider",
Method: http.MethodDelete,
Path: "/api/scim/service-provider/{id}",
Summary: "Delete SCIM service provider",
Tags: []string{"SCIM"},
DefaultStatus: http.StatusNoContent,
}, controller.deleteServiceProviderHandler, auth)
}
type ScimController struct {
scimService *service.ScimService
}
// syncServiceProviderHandler godoc
// @Summary Sync SCIM service provider
// @Description Trigger synchronization for a SCIM service provider
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 200 "OK"
// @Router /api/scim/service-provider/{id}/sync [post]
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
func (c *ScimController) syncServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) {
if err := c.scimService.SyncServiceProvider(ctx, input.ID); err != nil {
return nil, err
}
ctx.Status(http.StatusOK)
return &httpapi.EmptyOutput{}, nil
}
// createServiceProviderHandler godoc
// @Summary Create SCIM service provider
// @Description Create a new SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
// @Router /api/scim/service-provider [post]
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
func (c *ScimController) createServiceProviderHandler(ctx context.Context, input *scimCreateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := c.scimService.CreateServiceProvider(ctx, &input.Body)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, providerDTO)
return mapSCIMProvider(provider)
}
// updateServiceProviderHandler godoc
// @Summary Update SCIM service provider
// @Description Update an existing SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param id path string true "Service Provider ID"
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
// @Router /api/scim/service-provider/{id} [put]
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
func (c *ScimController) updateServiceProviderHandler(ctx context.Context, input *scimUpdateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := c.scimService.UpdateServiceProvider(ctx, input.ID, &input.Body)
if err != nil {
_ = ctx.Error(err)
return
return nil, err
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, providerDTO)
return mapSCIMProvider(provider)
}
// deleteServiceProviderHandler godoc
// @Summary Delete SCIM service provider
// @Description Delete a SCIM service provider by ID
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 204 "No Content"
// @Router /api/scim/service-provider/{id} [delete]
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
func (c *ScimController) deleteServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) {
if err := c.scimService.DeleteServiceProvider(ctx, input.ID); err != nil {
return nil, err
}
ctx.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
func mapSCIMProvider(provider any) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
var output dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &output); err != nil {
return nil, err
}
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,250 +1,185 @@
package controller
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewUserGroupController creates a new controller for user group management
// @Summary User group management controller
// @Description Initializes all user group-related API endpoints
// @Tags User Groups
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
ugc := UserGroupController{
UserGroupService: userGroupService,
}
type userGroupListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
}
userGroupsGroup := group.Group("/user-groups")
userGroupsGroup.Use(authMiddleware.Add())
{
userGroupsGroup.GET("", ugc.list)
userGroupsGroup.GET("/:id", ugc.get)
userGroupsGroup.POST("", ugc.create)
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
}
type userGroupIDInput struct {
ID string `path:"id"`
}
type userGroupCreateInput struct {
Body dto.UserGroupCreateDto
}
type userGroupUpdateInput struct {
ID string `path:"id"`
Body dto.UserGroupCreateDto
}
type userGroupUsersInput struct {
ID string `path:"id"`
Body dto.UserGroupUpdateUsersDto
}
type userGroupClientsInput struct {
ID string `path:"id"`
Body dto.UserGroupUpdateAllowedOidcClientsDto
}
// NewUserGroupController registers user group management routes
func NewUserGroupController(api huma.API, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
controller := &UserGroupController{UserGroupService: userGroupService}
auth := authMiddleware.Huma(api)
httpapi.Register(api, huma.Operation{
OperationID: "list-user-groups",
Method: http.MethodGet,
Path: "/api/user-groups",
Summary: "List user groups",
Tags: []string{"User Groups"},
}, controller.list, auth)
httpapi.Register(api, huma.Operation{
OperationID: "get-user-group",
Method: http.MethodGet,
Path: "/api/user-groups/{id}",
Summary: "Get user group by ID",
Tags: []string{"User Groups"},
}, controller.get, auth)
httpapi.Register(api, huma.Operation{
OperationID: "create-user-group",
Method: http.MethodPost,
Path: "/api/user-groups",
Summary: "Create user group",
Tags: []string{"User Groups"},
DefaultStatus: http.StatusCreated,
}, controller.create, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-user-group",
Method: http.MethodPut,
Path: "/api/user-groups/{id}",
Summary: "Update user group",
Tags: []string{"User Groups"},
}, controller.update, auth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-user-group",
Method: http.MethodDelete,
Path: "/api/user-groups/{id}",
Summary: "Delete user group",
Tags: []string{"User Groups"},
DefaultStatus: http.StatusNoContent,
}, controller.delete, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-user-group-users",
Method: http.MethodPut,
Path: "/api/user-groups/{id}/users",
Summary: "Update users in a group",
Tags: []string{"User Groups"},
}, controller.updateUsers, auth)
httpapi.Register(api, huma.Operation{
OperationID: "update-user-group-allowed-oidc-clients",
Method: http.MethodPut,
Path: "/api/user-groups/{id}/allowed-oidc-clients",
Summary: "Update allowed OIDC clients",
Tags: []string{"User Groups"},
}, controller.updateAllowedOIDCClients, auth)
}
type UserGroupController struct {
UserGroupService *service.UserGroupService
}
// list godoc
// @Summary List user groups
// @Description Get a paginated list of user groups with optional search and sorting
// @Tags User Groups
// @Param search query string false "Search term to filter user groups by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
func (ugc *UserGroupController) list(ctx context.Context, input *userGroupListInput) (*httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]], error) {
groups, pagination, err := ugc.UserGroupService.List(ctx, input.Search, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
groupsDTO := make([]dto.UserGroupMinimalDto, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupMinimalDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
if err := dto.MapStruct(group, &groupsDTO[i]); err != nil {
return nil, err
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
groupsDTO[i].UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
Data: groupsDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]]{Body: dto.Paginated[dto.UserGroupMinimalDto]{Data: groupsDTO, Pagination: pagination}}, nil
}
// get godoc
// @Summary Get user group by ID
// @Description Retrieve detailed information about a specific user group including its users
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
func (ugc *UserGroupController) get(ctx context.Context, input *userGroupIDInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.Get(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
return mapUserGroup(group)
}
// create godoc
// @Summary Create user group
// @Description Create a new user group
// @Tags User Groups
// @Accept json
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDto "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
func (ugc *UserGroupController) create(ctx context.Context, input *userGroupCreateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.Create(ctx, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, groupDto)
return mapUserGroup(group)
}
// update godoc
// @Summary Update user group
// @Description Update an existing user group by ID
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
func (ugc *UserGroupController) update(ctx context.Context, input *userGroupUpdateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.Update(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
return mapUserGroup(group)
}
// delete godoc
// @Summary Delete user group
// @Description Delete a specific user group by ID
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
func (ugc *UserGroupController) delete(ctx context.Context, input *userGroupIDInput) (*httpapi.EmptyOutput, error) {
if err := ugc.UserGroupService.Delete(ctx, input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// updateUsers godoc
// @Summary Update users in a group
// @Description Update the list of users belonging to a specific user group
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
func (ugc *UserGroupController) updateUsers(ctx context.Context, input *userGroupUsersInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.UpdateUsers(ctx, input.ID, input.Body.UserIDs)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
return mapUserGroup(group)
}
// updateAllowedOidcClients godoc
// @Summary Update allowed OIDC clients
// @Description Update the OIDC clients allowed for a specific user group
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
var input dto.UserGroupUpdateAllowedOidcClientsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
func (ugc *UserGroupController) updateAllowedOIDCClients(ctx context.Context, input *userGroupClientsInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.UpdateAllowedOidcClient(ctx, input.ID, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userGroupDto)
return mapUserGroup(group)
}
func mapUserGroup(group any) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
var output dto.UserGroupDto
if err := dto.MapStruct(group, &output); err != nil {
return nil, err
}
return &httpapi.BodyOutput[dto.UserGroupDto]{Body: output}, nil
}

View File

@@ -1,56 +1,63 @@
package controller
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
type versionOutput struct {
CacheControl string `header:"Cache-Control"`
Body map[string]string
}
// NewVersionController registers version-related routes
func NewVersionController(api huma.API, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
httpapi.Register(api, huma.Operation{
OperationID: "get-latest-version",
Method: http.MethodGet,
Path: "/api/version/latest",
Summary: "Get latest available version of Pocket ID",
Tags: []string{"Version"},
}, vc.getLatestVersionHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-current-version",
Method: http.MethodGet,
Path: "/api/version/current",
Summary: "Get current deployed version of Pocket ID",
Tags: []string{"Version"},
}, vc.getCurrentVersionHandler, userAuth)
}
type VersionController struct {
versionService *service.VersionService
}
// getLatestVersionHandler godoc
// @Summary Get latest available version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
func (vc *VersionController) getLatestVersionHandler(ctx context.Context, _ *httpapi.EmptyInput) (*versionOutput, error) {
tag, err := vc.versionService.GetLatestVersion(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
cacheControl := ""
if !httpapi.QueryPresent(ctx, "skipCache") {
cacheControl = utils.CacheControlValue(5*time.Minute, 15*time.Minute)
}
return &versionOutput{CacheControl: cacheControl, Body: map[string]string{"latestVersion": tag}}, nil
}
// getCurrentVersionHandler godoc
// @Summary Get current deployed version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Current version information"
// @Router /api/version/current [get]
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"currentVersion": common.Version,
})
func (vc *VersionController) getCurrentVersionHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) {
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"currentVersion": common.Version}}, nil
}

View File

@@ -1,36 +1,52 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// NewWellKnownController creates a new controller for OIDC discovery endpoints
// @Summary OIDC Discovery controller
// @Description Initializes OIDC discovery and JWKS endpoints
// @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService}
type wellKnownOutput struct {
ContentType string `header:"Content-Type"`
Body []byte
}
// NewWellKnownController registers OIDC discovery endpoints
func NewWellKnownController(api huma.API, jwtService *service.JwtService) {
controller := &WellKnownController{jwtService: jwtService}
// Pre-compute the OIDC configuration document, which is static
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
controller.oidcConfig, err = controller.computeOIDCConfiguration()
if err != nil {
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-jwks",
Method: http.MethodGet,
Path: "/.well-known/jwks.json",
Summary: "Get JSON Web Key Set",
Tags: []string{"Well Known"},
}, controller.jwksHandler)
httpapi.Register(api, huma.Operation{
OperationID: "get-openid-configuration",
Method: http.MethodGet,
Path: "/.well-known/openid-configuration",
Summary: "Get OpenID Connect discovery configuration",
Tags: []string{"Well Known"},
}, controller.openIDConfigurationHandler)
}
type WellKnownController struct {
@@ -38,51 +54,35 @@ type WellKnownController struct {
oidcConfig []byte
}
// jwksHandler godoc
// @Summary Get JSON Web Key Set (JWKS)
// @Description Returns the JSON Web Key Set used for token verification
// @Tags Well Known
// @Produce json
// @Success 200 {object} object "{ \"keys\": []interface{} }"
// @Router /.well-known/jwks.json [get]
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
func (wkc *WellKnownController) jwksHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: jwks}, nil
}
// openIDConfigurationHandler godoc
// @Summary Get OpenID Connect discovery configuration
// @Description Returns the OpenID Connect discovery document with endpoints and capabilities
// @Tags Well Known
// @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
func (wkc *WellKnownController) openIDConfigurationHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: wkc.oidcConfig}, nil
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
internalAppUrl := common.EnvConfig.InternalAppURL
appURL := common.EnvConfig.AppURL
internalAppURL := common.EnvConfig.InternalAppURL
alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": internalAppUrl + "/api/oidc/token",
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"issuer": appURL,
"authorization_endpoint": appURL + "/authorize",
"token_endpoint": internalAppURL + "/api/oidc/token",
"userinfo_endpoint": internalAppURL + "/api/oidc/userinfo",
"end_session_endpoint": appURL + "/api/oidc/end-session",
"introspection_endpoint": internalAppURL + "/api/oidc/introspect",
"device_authorization_endpoint": appURL + "/api/oidc/device/authorize",
"jwks_uri": internalAppURL + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"},
@@ -96,7 +96,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"request_object_signing_alg_values_supported": []string{"none"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"pushed_authorization_request_endpoint": internalAppURL + "/api/oidc/par",
"require_pushed_authorization_requests": false,
}
return json.Marshal(config)

View File

@@ -1,19 +0,0 @@
package devicelogin
import (
"context"
"time"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// CleanupExpiredRequests deletes device login requests that have expired
// It returns the number of rows removed
func CleanupExpiredRequests(ctx context.Context, db *gorm.DB) (int64, error) {
statement := db.
WithContext(ctx).
Delete(&Request{}, "expires_at < ?", datatype.DateTime(time.Now()))
return statement.RowsAffected, statement.Error
}

View File

@@ -1,46 +0,0 @@
//go:build unit
package devicelogin
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"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"
)
func TestCleanupExpiredRequests(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
requests := []Request{
{
Base: model.Base{ID: "expired-device-login-request"},
Code: "PAAAAAAA",
DeviceTokenHash: "expired-token-hash",
Status: RequestStatusPending,
ExpiresAt: datatype.DateTime(time.Now().Add(-time.Minute)),
UserAgent: "expired-agent",
},
{
Base: model.Base{ID: "active-device-login-request"},
Code: "PBBBBBBB",
DeviceTokenHash: "active-token-hash",
Status: RequestStatusPending,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
UserAgent: "active-agent",
},
}
require.NoError(t, db.Create(&requests).Error)
deleted, err := CleanupExpiredRequests(t.Context(), db)
require.NoError(t, err)
require.Equal(t, int64(1), deleted)
var remaining []Request
require.NoError(t, db.Order("id").Find(&remaining).Error)
require.Len(t, remaining, 1)
require.Equal(t, "active-device-login-request", remaining[0].ID)
}

View File

@@ -1,28 +0,0 @@
package devicelogin
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type requestCreateDto struct {
ID string `json:"id"`
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
Interval int `json:"interval"`
}
type verificationDto struct {
Code string `json:"code" binding:"required"`
}
type decisionDto struct {
Code string `json:"code" binding:"required"`
Decision string `json:"decision" binding:"required,oneof=approve deny"`
}
type verificationInfoDto struct {
UserCode string `json:"userCode"`
Device string `json:"device"`
IPAddress string `json:"ipAddress"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
}

View File

@@ -1,134 +0,0 @@
package devicelogin
import (
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
type handler struct {
service *Service
baseURL string
appConfig AppConfigProvider
}
func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *handler {
return &handler{
service: service,
baseURL: baseURL,
appConfig: appConfig,
}
}
// createRequest godoc
// @Summary Create device login request
// @Description Create a short-lived request that can be approved from another authenticated device
// @Tags Device Login
// @Produce json
// @Success 201 {object} requestCreateDto "Created device login request"
// @Router /api/device-login/requests [post]
func (h *handler) createRequest(c *gin.Context) {
request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
verificationURI := h.baseURL + "/device"
verificationURIComplete := verificationURI + "?user_code=" + url.QueryEscape(request.Code)
cookie.AddDeviceLoginTokenCookie(c, request.ID, deviceToken)
c.JSON(http.StatusCreated, requestCreateDto{
ID: request.ID,
UserCode: request.Code,
VerificationURI: verificationURI,
VerificationURIComplete: verificationURIComplete,
ExpiresAt: request.ExpiresAt,
Interval: PollingInterval,
})
}
// exchangeRequest godoc
// @Summary Exchange device login request
// @Description Poll a device login request and create a browser session after it has been approved
// @Tags Device Login
// @Produce json
// @Param id path string true "Device login request ID"
// @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) {
requestID := c.Param("id")
deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName)
user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
if status == RequestStatusPending {
c.Status(http.StatusAccepted)
return
}
var userDto dto.UserDto
if err = dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
c.JSON(http.StatusOK, userDto)
}
// inspectRequest godoc
// @Summary Inspect device login request
// @Description Retrieve the requesting device details for an authenticated user before approval or denial
// @Tags Device Login
// @Accept json
// @Produce json
// @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) {
var input verificationDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
info, err := h.service.Inspect(c.Request.Context(), input.Code)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, verificationInfoDto(info))
}
// decideRequest godoc
// @Summary Decide device login request
// @Description Approve or deny a device login request; approval requires fresh passkey reauthentication
// @Tags Device Login
// @Accept json
// @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) {
var input decisionDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
if err := h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,32 +0,0 @@
package devicelogin
import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type RequestStatus string
const (
RequestStatusPending RequestStatus = "pending"
RequestStatusApproved RequestStatus = "approved"
RequestStatusDenied RequestStatus = "denied"
)
type Request struct {
model.Base
Code string
DeviceTokenHash string
Status RequestStatus
ExpiresAt datatype.DateTime
IpAddress string
UserAgent string
UserID *string
User model.User
}
func (Request) TableName() string {
return "device_login_requests"
}

View File

@@ -1,59 +0,0 @@
package devicelogin
import (
"context"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
type TokenService interface {
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
}
type ReauthenticationTokenConsumer interface {
ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error)
}
type AuditLogger interface {
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
DeviceStringFromUserAgent(userAgent string) string
}
type AppConfigProvider interface {
GetDbConfig() *model.AppConfig
}
type Dependencies struct {
DB *gorm.DB
BaseURL string
Signer TokenService
Reauth ReauthenticationTokenConsumer
AuditLog AuditLogger
AppConfig AppConfigProvider
}
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps)
return &Module{
service: service,
handler: newHandler(service, deps.BaseURL, deps.AppConfig),
}
}
// RegisterRoutes mounts the public exchange and authenticated verification endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, verificationRateLimit gin.HandlerFunc) {
apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest)
apiGroup.POST("/device-login/requests/:id/exchange", m.handler.exchangeRequest)
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest)
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest)
}

View File

@@ -1,242 +0,0 @@
package devicelogin
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
RequestDuration = 15 * time.Minute
PollingInterval = 3
codePrefix = "P"
codeRandomLength = 7
reauthenticationMaxAge = time.Minute
// authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device
authenticationMethodOneTimePassword = "otp"
)
type Service struct {
db *gorm.DB
signer TokenService
auditLog AuditLogger
reauth ReauthenticationTokenConsumer
}
type VerificationInfo struct {
UserCode string
Device string
IPAddress string
ExpiresAt datatype.DateTime
}
func newService(deps Dependencies) *Service {
return &Service{
db: deps.DB,
signer: deps.Signer,
auditLog: deps.AuditLog,
reauth: deps.Reauth,
}
}
func (s *Service) Create(ctx context.Context, ipAddress, userAgent string) (Request, string, error) {
// Bind the public request to a separate high-entropy secret that never enters the QR code
deviceToken, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return Request{}, "", err
}
now := time.Now().Round(time.Second)
request := Request{
DeviceTokenHash: utils.CreateSha256Hash(deviceToken),
Status: RequestStatusPending,
ExpiresAt: datatype.DateTime(now.Add(RequestDuration)),
UserAgent: userAgent,
IpAddress: ipAddress,
}
// Retry code generation because of the small but non-zero chance of a collision with an existing code
for range 3 {
request.Code, err = newUserCode()
if err != nil {
return Request{}, "", err
}
err = s.db.WithContext(ctx).Create(&request).Error
if err == nil {
return request, deviceToken, nil
}
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return Request{}, "", err
}
}
return Request{}, "", errors.New("failed to generate a unique device login code")
}
func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) {
code = strings.ToUpper(strings.TrimSpace(code))
// Return requester metadata only while the request can still be decided
var request Request
err := s.db.
WithContext(ctx).
Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())).
First(&request).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return VerificationInfo{}, &common.DeviceLoginRequestInvalidOrExpiredError{}
}
return VerificationInfo{}, err
}
return VerificationInfo{
UserCode: request.Code,
Device: s.auditLog.DeviceStringFromUserAgent(request.UserAgent),
IPAddress: request.IpAddress,
ExpiresAt: request.ExpiresAt,
}, nil
}
func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error {
code = strings.ToUpper(strings.TrimSpace(code))
tx := s.db.Begin()
defer tx.Rollback()
var request Request
err := tx.
WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())).
First(&request).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.DeviceLoginRequestInvalidOrExpiredError{}
}
return err
}
switch decision {
case "approve":
// Consume the fresh passkey proof in the same transaction as the approval
if reauthenticationToken == "" {
return &common.ReauthenticationRequiredError{}
}
reauthenticatedAt, consumeErr := s.reauth.ConsumeReauthenticationToken(ctx, tx, reauthenticationToken, userID)
if consumeErr != nil {
return consumeErr
}
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
return &common.ReauthenticationRequiredError{}
}
request.Status = RequestStatusApproved
request.UserID = &userID
case "deny":
request.Status = RequestStatusDenied
default:
return fmt.Errorf("unsupported device login decision %q", decision)
}
result := tx.
WithContext(ctx).
Model(&Request{}).
Where("id = ? AND status = ? AND expires_at > ?", request.ID, RequestStatusPending, datatype.DateTime(time.Now())).
Updates(map[string]any{"status": request.Status, "user_id": request.UserID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return &common.DeviceLoginRequestInvalidOrExpiredError{}
}
return tx.Commit().Error
}
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string) (model.User, string, RequestStatus, error) {
if deviceToken == "" {
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var request Request
err := tx.
WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("User").
Where("id = ? AND device_token_hash = ? AND expires_at > ?", requestID, utils.CreateSha256Hash(deviceToken), datatype.DateTime(time.Now())).
First(&request).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
}
return model.User{}, "", "", err
}
switch request.Status {
case RequestStatusPending:
// Leave pending requests untouched so the waiting device can continue polling
return model.User{}, "", request.Status, nil
case RequestStatusDenied:
return model.User{}, "", request.Status, &common.DeviceLoginDeniedError{}
case RequestStatusApproved:
default:
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
}
if request.UserID == nil || request.User.ID == "" {
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
}
if request.User.Disabled {
return model.User{}, "", "", &common.UserDisabledError{}
}
// Mint the session with login-code semantics because the waiting device did not perform WebAuthn
accessToken, err := s.signer.GenerateAccessToken(request.User, authenticationMethodOneTimePassword)
if err != nil {
return model.User{}, "", "", err
}
result := tx.
WithContext(ctx).
Where("id = ? AND status = ?", request.ID, RequestStatusApproved).
Delete(&Request{})
if result.Error != nil {
return model.User{}, "", "", result.Error
}
if result.RowsAffected != 1 {
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
}
s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, request.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", "", err
}
return request.User, accessToken, request.Status, nil
}
func newUserCode() (string, error) {
randomCode, err := utils.GenerateRandomUppercaseUnambiguousString(codeRandomLength)
if err != nil {
return "", err
}
return codePrefix + randomCode, nil
}

View File

@@ -1,269 +0,0 @@
package devicelogin
import (
"context"
"strings"
"sync"
"testing"
"time"
"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/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeReauthenticationTokenConsumer struct {
expectedValue string
createdAt time.Time
}
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
if token != f.expectedValue {
return time.Time{}, &common.ReauthenticationRequiredError{}
}
if !f.createdAt.IsZero() {
return f.createdAt, nil
}
return time.Now(), nil
}
type fakeTokenService struct {
mu sync.Mutex
userID string
authenticationMethod string
}
func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.userID = user.ID
f.authenticationMethod = authenticationMethod
return "device-login-access-token", nil
}
func (f *fakeTokenService) generatedToken() (string, string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.userID, f.authenticationMethod
}
type auditEntry struct {
event model.AuditLogEvent
ipAddress string
userAgent string
userID string
}
type fakeAuditLogger struct {
mu sync.Mutex
entries []auditEntry
}
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
return model.AuditLog{}, true
}
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
return "Parsed " + userAgent
}
func (f *fakeAuditLogger) lastEntry() auditEntry {
f.mu.Lock()
defer f.mu.Unlock()
return f.entries[len(f.entries)-1]
}
func TestRequestLifecycle(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
deviceLoginService, signer, auditLog := newServiceForTest(db)
user := model.User{
Base: model.Base{ID: "device-login-user"},
Username: "device-login-user",
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := deviceLoginService.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0")
require.NoError(t, err)
require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$`, request.Code)
require.Equal(t, utils.CreateSha256Hash(deviceToken), request.DeviceTokenHash)
require.NotEqual(t, deviceToken, request.DeviceTokenHash)
require.Equal(t, RequestStatusPending, request.Status)
info, err := deviceLoginService.Inspect(t.Context(), strings.ToLower(request.Code))
require.NoError(t, err)
require.Equal(t, request.Code, info.UserCode)
require.Equal(t, "192.0.2.10", info.IPAddress)
require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device)
err = deviceLoginService.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
require.NoError(t, err)
exchangedUser, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent")
require.NoError(t, err)
require.Equal(t, RequestStatusApproved, status)
require.Equal(t, user.ID, exchangedUser.ID)
require.Equal(t, "device-login-access-token", accessToken)
signedUserID, authenticationMethod := signer.generatedToken()
require.Equal(t, user.ID, signedUserID)
require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod)
var requestCount int64
require.NoError(t, db.Model(&Request{}).Where("id = ?", request.ID).Count(&requestCount).Error)
require.Zero(t, requestCount)
entry := auditLog.lastEntry()
require.Equal(t, model.AuditLogEventRemoteSignIn, entry.event)
require.Equal(t, "198.51.100.20", entry.ipAddress)
require.Equal(t, "target-agent", entry.userAgent)
require.Equal(t, user.ID, entry.userID)
}
func TestPendingAndDeniedRequests(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
deviceLoginService, _, _ := newServiceForTest(db)
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
user, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
require.NoError(t, err)
require.Equal(t, RequestStatusPending, status)
require.Empty(t, user.ID)
require.Empty(t, accessToken)
err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
require.NoError(t, err)
user, accessToken, status, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
var deniedError *common.DeviceLoginDeniedError
require.ErrorAs(t, err, &deniedError)
require.Equal(t, RequestStatusDenied, status)
require.Empty(t, user.ID)
require.Empty(t, accessToken)
}
func TestRejectsInvalidAndExpiredRequests(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
deviceLoginService, _, _ := newServiceForTest(db)
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
_, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, "wrong-token", "", "")
var invalidError *common.DeviceLoginRequestInvalidOrExpiredError
require.ErrorAs(t, err, &invalidError)
require.NoError(t, db.Model(&request).Update("expires_at", datatype.DateTime(time.Now().Add(-time.Minute))).Error)
_, err = deviceLoginService.Inspect(t.Context(), request.Code)
require.ErrorAs(t, err, &invalidError)
err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
require.ErrorAs(t, err, &invalidError)
_, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
require.ErrorAs(t, err, &invalidError)
}
func TestRejectsDisabledUserAtExchange(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
deviceLoginService, _, _ := newServiceForTest(db)
user := model.User{
Base: model.Base{ID: "disabled-device-login-user"},
Username: "disabled-device-login-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
_, accessToken, _, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
var disabledError *common.UserDisabledError
require.ErrorAs(t, err, &disabledError)
require.Empty(t, accessToken)
var remainingRequest Request
require.NoError(t, db.First(&remainingRequest, "id = ?", request.ID).Error)
}
func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
deviceLoginService, _, _ := newServiceForTest(db)
request, _, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
var reauthenticationError *common.ReauthenticationRequiredError
require.ErrorAs(t, err, &reauthenticationError)
deviceLoginService.reauth = &fakeReauthenticationTokenConsumer{
expectedValue: "stale-proof",
createdAt: time.Now().Add(-2 * time.Minute),
}
err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
require.ErrorAs(t, err, &reauthenticationError)
var persistedRequest Request
require.NoError(t, db.First(&persistedRequest, "id = ?", request.ID).Error)
require.Equal(t, RequestStatusPending, persistedRequest.Status)
}
func TestExchangeIsSingleUse(t *testing.T) {
db := testutils.NewConcurrentDatabaseForTest(t)
deviceLoginService, _, _ := newServiceForTest(db)
user := model.User{
Base: model.Base{ID: "single-use-device-login-user"},
Username: "single-use-device-login-user",
}
require.NoError(t, db.Create(&user).Error)
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
require.NoError(t, err)
require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
var waitGroup sync.WaitGroup
results := make(chan error, 2)
for range 2 {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
_, _, _, exchangeErr := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
results <- exchangeErr
}()
}
waitGroup.Wait()
close(results)
var successes int
for result := range results {
if result == nil {
successes++
}
}
require.Equal(t, 1, successes)
}
func newServiceForTest(db *gorm.DB) (*Service, *fakeTokenService, *fakeAuditLogger) {
signer := &fakeTokenService{}
auditLog := &fakeAuditLogger{}
deviceLoginService := newService(Dependencies{
DB: db,
Signer: signer,
AuditLog: auditLog,
Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"},
})
return deviceLoginService, signer, auditLog
}

View File

@@ -1,5 +1,12 @@
package dto
import (
"encoding/json"
"net/mail"
"github.com/danielgtaylor/huma/v2"
)
type PublicAppConfigVariableDto struct {
Key string `json:"key"`
Type string `json:"type"`
@@ -12,47 +19,64 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"`
HomePageURL string `json:"homePageUrl" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword"`
LdapBase string `json:"ldapBase"`
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAdminGroupName string `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
AppName string `json:"appName" required:"false" minLength:"1" maxLength:"30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" required:"false"`
HomePageURL string `json:"homePageUrl" required:"false"`
EmailsVerified string `json:"emailsVerified" required:"false"`
DisableAnimations string `json:"disableAnimations" required:"false"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" required:"false"`
AllowUserSignups string `json:"allowUserSignups" required:"false" enum:"disabled,withToken,open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" required:"false"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" required:"false"`
AccentColor string `json:"accentColor" required:"false"`
RequireUserEmail string `json:"requireUserEmail" required:"false"`
SmtpHost string `json:"smtpHost" required:"false"`
SmtpPort string `json:"smtpPort" required:"false"`
SmtpFrom string `json:"smtpFrom" required:"false"`
SmtpUser string `json:"smtpUser" required:"false"`
SmtpPassword string `json:"smtpPassword" required:"false"`
SmtpTls string `json:"smtpTls" required:"false" enum:"none,starttls,tls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify" required:"false"`
LdapEnabled string `json:"ldapEnabled" required:"false"`
LdapUrl string `json:"ldapUrl" required:"false"`
LdapBindDn string `json:"ldapBindDn" required:"false"`
LdapBindPassword string `json:"ldapBindPassword" required:"false"`
LdapBase string `json:"ldapBase" required:"false"`
LdapUserSearchFilter string `json:"ldapUserSearchFilter" required:"false"`
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter" required:"false"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify" required:"false"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier" required:"false"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername" required:"false"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail" required:"false"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName" required:"false"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName" required:"false"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName" required:"false"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture" required:"false"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember" required:"false"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier" required:"false"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName" required:"false"`
LdapAdminGroupName string `json:"ldapAdminGroupName" required:"false"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" required:"false"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" required:"false"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" required:"false"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" required:"false"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" required:"false"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" required:"false"`
}
func (d *AppConfigUpdateDto) Resolve(huma.Context) []error {
var errs []error
if d.SmtpFrom != "" {
address, err := mail.ParseAddress(d.SmtpFrom)
if err != nil || address.Address != d.SmtpFrom {
errs = append(errs, &huma.ErrorDetail{Location: "body.smtpFrom", Message: "Field validation for 'SmtpFrom' failed on the 'email' tag"})
}
}
if d.SignupDefaultUserGroupIDs != "" && !json.Valid([]byte(d.SignupDefaultUserGroupIDs)) {
errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultUserGroupIDs", Message: "Signup default user group IDs must be valid JSON"})
}
if d.SignupDefaultCustomClaims != "" && !json.Valid([]byte(d.SignupDefaultCustomClaims)) {
errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultCustomClaims", Message: "Signup default custom claims must be valid JSON"})
}
return errs
}

View File

@@ -6,6 +6,6 @@ type CustomClaimDto struct {
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required" unorm:"nfc"`
Value string `json:"value" binding:"required" unorm:"nfc"`
Key string `json:"key" required:"true" unorm:"nfc"`
Value string `json:"value" required:"true" unorm:"nfc"`
}

View File

@@ -3,8 +3,6 @@ package dto
import (
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"golang.org/x/text/unicode/norm"
)
@@ -68,7 +66,3 @@ loop:
fv.SetString(val)
}
}
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
}

View File

@@ -1,6 +1,9 @@
package dto
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
import (
"github.com/danielgtaylor/huma/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type OidcClientMetaDataDto struct {
ID string `json:"id"`
@@ -36,27 +39,27 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
Name string `json:"name" required:"true" maxLength:"50" unorm:"nfc"`
Description string `json:"description" required:"false" maxLength:"150" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" required:"false"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" required:"false"`
IsPublic bool `json:"isPublic" required:"false"`
PkceEnabled bool `json:"pkceEnabled" required:"false"`
RequiresReauthentication bool `json:"requiresReauthentication" required:"false"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests" required:"false"`
SkipConsent bool `json:"skipConsent" required:"false"`
Credentials OidcClientCredentialsDto `json:"credentials" required:"false"`
LaunchURL *string `json:"launchURL" required:"false" format:"uri"`
HasLogo bool `json:"hasLogo" required:"false"`
HasDarkLogo bool `json:"hasDarkLogo" required:"false"`
LogoURL *string `json:"logoUrl" required:"false"`
DarkLogoURL *string `json:"darkLogoUrl" required:"false"`
IsGroupRestricted bool `json:"isGroupRestricted" required:"false"`
}
type OidcClientCreateDto struct {
OidcClientUpdateDto
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"`
}
type OidcClientCredentialsDto struct {
@@ -64,15 +67,42 @@ type OidcClientCredentialsDto struct {
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Issuer string `json:"issuer" required:"false"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
ReplayProtection bool `json:"replayProtection"`
ReplayProtection bool `json:"replayProtection" required:"false"`
}
type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
UserGroupIDs []string `json:"userGroupIds" required:"true"`
}
func (d *OidcClientUpdateDto) Resolve(huma.Context) []error {
return validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs)
}
func (d *OidcClientCreateDto) Resolve(huma.Context) []error {
errs := validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs)
if d.ID != "" && !ValidateClientID(d.ID) {
errs = append(errs, &huma.ErrorDetail{Location: "body.id", Message: "Client ID is invalid"})
}
return errs
}
func validateCallbackURLLists(callbackURLs, logoutCallbackURLs []string) []error {
var errs []error
for _, callbackURL := range callbackURLs {
if !ValidateCallbackURLPattern(callbackURL) {
errs = append(errs, &huma.ErrorDetail{Location: "body.callbackURLs", Message: "Callback URL pattern is invalid"})
}
}
for _, callbackURL := range logoutCallbackURLs {
if !ValidateCallbackURLPattern(callbackURL) {
errs = append(errs, &huma.ErrorDetail{Location: "body.logoutCallbackURLs", Message: "Logout callback URL pattern is invalid"})
}
}
return errs
}
type OidcLogoutDto struct {

View File

@@ -1,16 +1,34 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
import (
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type OneTimeAccessTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
TTL utils.JSONDuration `json:"ttl" required:"false"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
Email string `json:"email" required:"true" format:"email" unorm:"nfc"`
RedirectPath string `json:"redirectPath" required:"false"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
TTL utils.JSONDuration `json:"ttl" required:"false"`
}
func (d *OneTimeAccessTokenCreateDto) Resolve(huma.Context) []error {
return resolveTTL(d.TTL)
}
func (d *OneTimeAccessEmailAsAdminDto) Resolve(huma.Context) []error {
return resolveTTL(d.TTL)
}
func resolveTTL(ttl utils.JSONDuration) []error {
if ValidateTTL(ttl) {
return nil
}
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
}

View File

@@ -1,8 +1,10 @@
package dto
import (
"net/url"
"time"
"github.com/danielgtaylor/huma/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -16,9 +18,17 @@ type ScimServiceProviderDTO struct {
}
type ScimServiceProviderCreateDTO struct {
Endpoint string `json:"endpoint" binding:"required,url"`
Token string `json:"token"`
OidcClientID string `json:"oidcClientId" binding:"required"`
Endpoint string `json:"endpoint" required:"true" format:"uri"`
Token string `json:"token" required:"false"`
OidcClientID string `json:"oidcClientId" required:"true"`
}
func (d *ScimServiceProviderCreateDTO) Resolve(huma.Context) []error {
endpoint, err := url.Parse(d.Endpoint)
if err != nil || !endpoint.IsAbs() {
return []error{&huma.ErrorDetail{Location: "body.endpoint", Message: "Endpoint must be an absolute URI"}}
}
return nil
}
type ScimUser struct {

View File

@@ -2,8 +2,10 @@ package dto
import (
"errors"
"net/mail"
"unicode/utf8"
"github.com/gin-gonic/gin/binding"
"github.com/danielgtaylor/huma/v2"
)
type UserDto struct {
@@ -23,34 +25,57 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified" required:"false"`
FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"`
LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"`
DisplayName string `json:"displayName" required:"false" maxLength:"100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin" required:"false"`
Locale *string `json:"locale" required:"false"`
Disabled bool `json:"disabled" required:"false"`
UserGroupIds []string `json:"userGroupIds" required:"false"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
func (u UserCreateDto) Resolve(huma.Context) []error {
if u.Email == nil {
return nil
}
address, err := mail.ParseAddress(*u.Email)
if err != nil || address.Address != *u.Email {
return []error{&huma.ErrorDetail{Location: "body.email", Message: "Field validation for 'Email' failed on the 'email' tag"}}
}
return nil
}
return e.Struct(u)
//nolint:staticcheck // LDAP callers and their tests rely on the existing capitalized validation text
func (u UserCreateDto) Validate() error {
if u.Username == "" {
return errors.New("Field validation for 'Username' failed on the 'required' tag")
}
if !ValidateUsername(u.Username) {
return errors.New("Field validation for 'Username' failed on the 'username' tag")
}
if utf8.RuneCountInString(u.Username) > 50 {
return errors.New("Field validation for 'Username' failed on the 'max' tag")
}
if utf8.RuneCountInString(u.FirstName) > 50 {
return errors.New("Field validation for 'FirstName' failed on the 'max' tag")
}
if utf8.RuneCountInString(u.LastName) > 50 {
return errors.New("Field validation for 'LastName' failed on the 'max' tag")
}
if utf8.RuneCountInString(u.DisplayName) > 100 {
return errors.New("Field validation for 'DisplayName' failed on the 'max' tag")
}
return nil
}
type EmailVerificationDto struct {
Token string `json:"token" binding:"required"`
Token string `json:"token" required:"true"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
UserGroupIds []string `json:"userGroupIds" required:"true"`
}

View File

@@ -63,17 +63,6 @@ func TestUserCreateDto_Validate(t *testing.T) {
},
wantErr: "Field validation for 'Username' failed on the 'username' tag",
},
{
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: new("not-an-email"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Email' failed on the 'email' tag",
},
{
name: "first name too short",
input: UserCreateDto{
@@ -112,3 +101,28 @@ func TestUserCreateDto_Validate(t *testing.T) {
})
}
}
func TestUserCreateDtoResolveEmail(t *testing.T) {
testCases := []struct {
name string
email *string
wantErr bool
}{
{name: "empty email", email: nil},
{name: "exact address", email: new("test@example.com")},
{name: "invalid address", email: new("not-an-email"), wantErr: true},
{name: "display name is not an exact address", email: new("Test User <test@example.com>"), wantErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
errs := (&UserCreateDto{Email: testCase.email}).Resolve(nil)
if testCase.wantErr {
require.Len(t, errs, 1)
require.ErrorContains(t, errs[0], "Field validation for 'Email' failed on the 'email' tag")
return
}
require.Empty(t, errs)
})
}
}

View File

@@ -2,8 +2,8 @@ package dto
import (
"errors"
"unicode/utf8"
"github.com/gin-gonic/gin/binding"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -29,26 +29,27 @@ type UserGroupMinimalDto struct {
}
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
OidcClientIDs []string `json:"oidcClientIds" required:"true"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
FriendlyName string `json:"friendlyName" required:"true" minLength:"2" maxLength:"50" unorm:"nfc"`
Name string `json:"name" required:"true" minLength:"2" maxLength:"255" unorm:"nfc"`
LdapID string `json:"-"`
}
func (g UserGroupCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
friendlyNameLength := utf8.RuneCountInString(g.FriendlyName)
if friendlyNameLength < 2 || friendlyNameLength > 50 {
return errors.New("friendly name is invalid")
}
return e.Struct(g)
nameLength := utf8.RuneCountInString(g.Name)
if nameLength < 2 || nameLength > 255 {
return errors.New("name is invalid")
}
return nil
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"`
UserIDs []string `json:"userIds" required:"true"`
}

View File

@@ -8,9 +8,6 @@ import (
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// [a-zA-Z0-9] : The username must start with an alphanumeric character
@@ -21,44 +18,7 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() {
engine := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
validators := map[string]validator.Func{
"username": func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
},
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
},
"callback_url": func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
},
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
"resource_uri": func(fl validator.FieldLevel) bool {
return ValidateResourceURI(fl.Field().String())
},
}
for k, v := range validators {
err := engine.RegisterValidation(k, v)
if err != nil {
panic("Failed to register custom validation for " + k + ": " + err.Error())
}
}
}
const maxTTL = 31 * 24 * time.Hour
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
@@ -70,6 +30,11 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateTTL validates optional API durations against the existing bounds
func ValidateTTL(ttl utils.JSONDuration) bool {
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}
// isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link
func isActiveContentScheme(scheme string) bool {
switch strings.ToLower(scheme) {

View File

@@ -10,7 +10,7 @@ type WebauthnCredentialDto struct {
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=50"`
Name string `json:"name" required:"true" minLength:"1" maxLength:"50"`
}

View File

@@ -11,7 +11,6 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/devicelogin"
"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"
@@ -36,7 +35,6 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
return errors.Join(
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearDeviceLoginRequests", jobDefWithJitter(24*time.Hour), jobs.clearDeviceLoginRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
@@ -77,18 +75,6 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return nil
}
// clearDeviceLoginRequests deletes device login requests that have expired
func (j *DbCleanupJobs) clearDeviceLoginRequests(ctx context.Context) error {
count, err := devicelogin.CleanupExpiredRequests(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired device login requests: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired device login requests", slog.Int64("count", count))
return nil
}
// clearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)

View File

@@ -2,11 +2,15 @@ package middleware
import (
"errors"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
@@ -62,7 +66,7 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
return clone
}
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth.
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth
func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
clone := &AuthMiddleware{
apiKeyMiddleware: m.apiKeyMiddleware,
@@ -75,60 +79,108 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
c.Set("authenticationTime", authenticationTime)
if c.IsAborted() {
return
}
c.Next()
return
}
// If JWT auth failed and the error is not a NotSignedInError, abort the request
if !errors.Is(err, &common.NotSignedInError{}) {
result, err := m.authenticate(c)
if err != nil {
c.Abort()
_ = c.Error(err)
return
}
if !m.options.AllowApiKeyAuth {
if m.options.SuccessOptional {
c.Next()
return
}
c.Abort()
if c.GetHeader("X-API-Key") != "" {
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
return
}
_ = c.Error(err)
return
if result.UserID != "" {
setGinAuthentication(c, result)
}
// JWT auth failed, try API key auth
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
if c.IsAborted() {
return
}
c.Next()
return
}
if m.options.SuccessOptional {
c.Next()
return
}
// Both JWT and API key auth failed
c.Abort()
_ = c.Error(err)
c.Next()
}
}
// Huma returns an operation decorator using the same authentication behavior as Add
func (m *AuthMiddleware) Huma(api huma.API) func(*huma.Operation) {
return func(operation *huma.Operation) {
operation.Security = m.securityRequirements()
if m.options.AdminRequired {
if operation.Extensions == nil {
operation.Extensions = map[string]any{}
}
operation.Extensions["x-pocket-id-admin-required"] = true
}
operation.Middlewares = append(operation.Middlewares, func(ctx huma.Context, next func(huma.Context)) {
result, err := m.authenticate(humagin.Unwrap(ctx))
if err != nil {
status := 500
message := "Something went wrong"
var appError common.AppError
if errors.As(err, &appError) {
status = appError.HttpStatusCode()
message = appError.Error()
}
_ = huma.WriteErr(api, ctx, status, message)
return
}
if result.UserID != "" {
ctx = httpapi.WithAuthentication(ctx, result.UserID, result.IsAdmin, result.AuthenticationMethod, result.AuthenticationTime)
}
next(ctx)
})
}
}
type authenticationResult struct {
UserID string
IsAdmin bool
AuthenticationMethod string
AuthenticationTime time.Time
}
func (m *AuthMiddleware) authenticate(c *gin.Context) (authenticationResult, error) {
// Return immediately after successful JWT authentication
userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
return authenticationResult{userID, isAdmin, authenticationMethod, authenticationTime}, nil
}
// Fall back only when JWT verification reports that the request is not signed in
if !errors.Is(err, &common.NotSignedInError{}) {
return authenticationResult{}, err
}
// Handle API-key-disabled routes before considering API-key authentication
if !m.options.AllowApiKeyAuth {
if m.options.SuccessOptional {
return authenticationResult{}, nil
}
if c.GetHeader("X-API-Key") != "" {
return authenticationResult{}, &common.APIKeyAuthNotAllowedError{}
}
return authenticationResult{}, err
}
// Attempt API-key authentication after JWT reports that the request is not signed in
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
return authenticationResult{UserID: userID, IsAdmin: isAdmin}, nil
}
if m.options.SuccessOptional {
return authenticationResult{}, nil
}
return authenticationResult{}, err
}
func (m *AuthMiddleware) securityRequirements() []map[string][]string {
requirements := []map[string][]string{
{"BearerAuth": {}},
{"SessionCookie": {}},
}
if m.options.AllowApiKeyAuth {
requirements = append(requirements, map[string][]string{"ApiKeyAuth": {}})
}
if m.options.SuccessOptional {
requirements = append([]map[string][]string{{}}, requirements...)
}
return requirements
}
func setGinAuthentication(c *gin.Context, result authenticationResult) {
c.Set("userID", result.UserID)
c.Set("userIsAdmin", result.IsAdmin)
c.Set("authenticationMethod", result.AuthenticationMethod)
c.Set("authenticationTime", result.AuthenticationTime)
}

View File

@@ -1,12 +1,14 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@@ -18,6 +20,7 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -91,6 +94,41 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
require.Equal(t, http.StatusNoContent, recorder.Code)
})
t.Run("Huma decorator preserves JWT-only behavior and documentation", func(t *testing.T) {
humaRouter := gin.New()
api := httpapi.New(humaRouter, humaRouter.Group("/"))
operation := huma.Operation{OperationID: "huma-protected", Method: http.MethodGet, Path: "/api/huma-protected"}
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api)(&operation)
require.Equal(t, []map[string][]string{{"BearerAuth": {}}, {"SessionCookie": {}}}, operation.Security)
httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil })
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil)
request.Header.Set("X-API-Key", apiKeyToken)
response := httptest.NewRecorder()
humaRouter.ServeHTTP(response, request)
require.Equal(t, http.StatusForbidden, response.Code)
require.JSONEq(t, `{"error":"API key authentication is not allowed for this endpoint"}`, response.Body.String())
request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil)
request.Header.Set("Authorization", "Bearer "+jwtToken)
response = httptest.NewRecorder()
humaRouter.ServeHTTP(response, request)
require.Equal(t, http.StatusNoContent, response.Code)
})
t.Run("Huma admin decorator records admin authorization separately from security scopes", func(t *testing.T) {
humaRouter := gin.New()
api := httpapi.New(humaRouter, humaRouter.Group("/"))
operation := huma.Operation{}
authMiddleware.Huma(api)(&operation)
require.Equal(t, true, operation.Extensions["x-pocket-id-admin-required"])
for _, requirement := range operation.Security {
for _, scopes := range requirement {
require.Empty(t, scopes)
}
}
})
}
func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User {

View File

@@ -2,13 +2,10 @@ package middleware
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/pocket-id/pocket-id/backend/internal/common"
"gorm.io/gorm"
)
@@ -29,24 +26,6 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
// Check for slice validation errors
svErr, ok := errors.AsType[binding.SliceValidationError](err)
if ok {
if errors.As(svErr[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
// AppError with description
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
if ok {
@@ -89,37 +68,3 @@ func errorResponseWithDescription(c *gin.Context, statusCode int, message string
ErrorDescription: description,
})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "resource_uri":
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
return combinedErrors
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -14,6 +16,20 @@ func NewFileSizeLimitMiddleware() *FileSizeLimitMiddleware {
return &FileSizeLimitMiddleware{}
}
// Huma returns a multipart size-limit middleware that preserves the existing error message
func (m *FileSizeLimitMiddleware) Huma(api huma.API, maxSize int64) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
ginCtx := humagin.Unwrap(ctx)
ginCtx.Request.Body = http.MaxBytesReader(ginCtx.Writer, ginCtx.Request.Body, maxSize)
if err := ginCtx.Request.ParseMultipartForm(maxSize); err != nil {
fileError := &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
_ = huma.WriteErr(api, ctx, fileError.HttpStatusCode(), fileError.Error())
return
}
next(ctx)
}
}
func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)

View File

@@ -0,0 +1,49 @@
package middleware
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
func TestHumaFileSizeLimitMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := httpapi.New(router, router.Group("/"))
operation := huma.Operation{OperationID: "multipart-overflow", Method: http.MethodPost, Path: "/api/upload"}
operation.Middlewares = append(operation.Middlewares, NewFileSizeLimitMiddleware().Huma(api, 64))
type uploadInput struct {
RawBody huma.MultipartFormFiles[struct {
File huma.FormFile `form:"file" required:"true"`
}]
}
httpapi.Register(api, operation, func(context.Context, *uploadInput) (*struct{}, error) {
t.Fatal("handler must not run after multipart overflow")
return nil, nil
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "large.bin")
require.NoError(t, err)
_, err = part.Write(bytes.Repeat([]byte("x"), 256))
require.NoError(t, err)
require.NoError(t, writer.Close())
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/upload", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusRequestEntityTooLarge, response.Code)
require.JSONEq(t, `{"error":"The file can't be larger than 64 bytes"}`, response.Body.String())
}

View File

@@ -60,7 +60,6 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return "", false, "", time.Time{}, &common.TokenInvalidError{}
}

View File

@@ -10,6 +10,8 @@ import (
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
@@ -19,17 +21,15 @@ import (
// Rate-limit policy names
// Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter
const (
RateLimitAPI = "api"
RateLimitSignup = "signup"
RateLimitWebauthnLogin = "webauthn-login"
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
RateLimitOneTimeAccessToken = "one-time-access-token"
RateLimitOneTimeAccessEmail = "one-time-access-email"
RateLimitDeviceLoginCreate = "device-login-create"
RateLimitDeviceLoginVerification = "device-login-verification"
RateLimitSendEmailVerification = "send-email-verification"
RateLimitVerifyEmail = "verify-email"
RateLimitInternal = "internal"
RateLimitAPI = "api"
RateLimitSignup = "signup"
RateLimitWebauthnLogin = "webauthn-login"
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
RateLimitOneTimeAccessToken = "one-time-access-token"
RateLimitOneTimeAccessEmail = "one-time-access-email"
RateLimitSendEmailVerification = "send-email-verification"
RateLimitVerifyEmail = "verify-email"
RateLimitInternal = "internal"
)
// RateLimitPolicy is the configuration for a single rate-limit actor
@@ -55,8 +55,6 @@ func RateLimitPolicies() []RateLimitPolicy {
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitOneTimeAccessEmail, Rate: 2, Per: 10 * time.Minute, Burst: 5},
{Name: RateLimitDeviceLoginCreate, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitDeviceLoginVerification, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitSendEmailVerification, Rate: 2, Per: 10 * time.Minute, Burst: 1},
{Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitInternal, Rate: 20, Per: time.Second, Burst: 20},
@@ -90,22 +88,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
// Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend
if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() {
c.Next()
return
}
// Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now
allowed, retryAfter, err := svc.Allow(c.Request.Context(), ip)
allowed, retryAfter, err := allowRequest(c.Request.Context(), svc, policy, ip)
if err != nil {
// Fail open so a limiter error does not turn away otherwise-valid traffic
if !errors.Is(err, context.Canceled) {
// A cancelled context just means the client went away, so it is not worth logging
slog.WarnContext(c.Request.Context(), "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err))
}
c.Next()
return
}
@@ -123,3 +107,53 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
c.Next()
}
}
// Huma returns a Huma middleware backed by the existing rate-limit service
func (m *RateLimitMiddleware) Huma(api huma.API, policy string) func(huma.Context, func(huma.Context)) {
if common.EnvConfig.DisableRateLimiting {
return func(ctx huma.Context, next func(huma.Context)) { next(ctx) }
}
svc := m.services[policy]
return func(ctx huma.Context, next func(huma.Context)) {
if svc == nil {
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Something went wrong")
return
}
ginCtx := humagin.Unwrap(ctx)
allowed, retryAfter, err := allowRequest(ctx.Context(), svc, policy, ginCtx.ClientIP())
if err != nil {
next(ctx)
return
}
if !allowed {
if retryAfter > 0 {
ctx.SetHeader("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
}
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, (&common.TooManyRequestsError{}).Error())
return
}
next(ctx)
}
}
func allowRequest(ctx context.Context, svc *ratelimit.RateLimitService, policy, ip string) (bool, time.Duration, error) {
// Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend
if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() {
return true, 0, nil
}
// Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now
allowed, retryAfter, err := svc.Allow(ctx, ip)
if err != nil {
// Fail open so a limiter error does not turn away otherwise-valid traffic
if !errors.Is(err, context.Canceled) {
// A cancelled context just means the client went away, so it is not worth logging
slog.WarnContext(ctx, "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err))
}
return true, 0, err
}
return allowed, retryAfter, nil
}

View File

@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/francis/host/local"
@@ -15,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -146,3 +148,33 @@ func TestRateLimitMiddleware(t *testing.T) {
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
})
}
func TestHumaRateLimitMiddleware(t *testing.T) {
originalEnvConfig := common.EnvConfig
t.Cleanup(func() { common.EnvConfig = originalEnvConfig })
common.EnvConfig.AppEnv = common.AppEnvProduction
common.EnvConfig.DisableRateLimiting = false
const policy = "huma-test-limit"
services := startRateLimitServices(t, RateLimitPolicy{Name: policy, Rate: 1, Per: time.Hour, Burst: 1})
router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
api := httpapi.New(router, router.Group("/"))
operation := huma.Operation{OperationID: "huma-rate-limit", Method: http.MethodGet, Path: "/api/rate-limit"}
operation.Middlewares = append(operation.Middlewares, NewRateLimitMiddleware(services).Huma(api, policy))
httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil })
request := func() *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/rate-limit", nil)
req.RemoteAddr = net.JoinHostPort("203.0.113.10", "12345")
response := httptest.NewRecorder()
router.ServeHTTP(response, req)
return response
}
require.Equal(t, http.StatusNoContent, request().Code)
response := request()
require.Equal(t, http.StatusTooManyRequests, response.Code)
require.NotEmpty(t, response.Header().Get("Retry-After"))
require.JSONEq(t, `{"error":"Too many requests"}`, response.Body.String())
}

View File

@@ -29,7 +29,6 @@ type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventRemoteSignIn AuditLogEvent = "REMOTE_SIGN_IN"
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"

View File

@@ -5,12 +5,18 @@ import (
"fmt"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time //nolint:recvcheck
// Schema documents the JSON representation rather than the underlying time.Time structure
func (date DateTime) Schema(huma.Registry) *huma.Schema {
return &huma.Schema{Type: huma.TypeString, Format: "date-time"}
}
func DateTimeFromString(str string) (DateTime, error) {
t, err := time.Parse(time.RFC3339Nano, str)
if err != nil {

View File

@@ -10,9 +10,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
@@ -106,37 +106,33 @@ func (h *authorizationHandler) authorize(c *gin.Context) {
h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
}
func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
interactionID := c.Param("id")
interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, interactionSession)
type interactionIDInput struct {
ID string `path:"id"`
}
func (h *authorizationHandler) completeInteraction(c *gin.Context) {
interactionID := c.Param("id")
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
type completeInteractionInput struct {
ID string `path:"id"`
Body completeInteractionRequest
}
var request completeInteractionRequest
if err := c.ShouldBindJSON(&request); err != nil {
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
return
}
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
func (h *authorizationHandler) getInteractionSession(ctx context.Context, input *interactionIDInput) (*httpapi.BodyOutput[interactionSessionForUser], error) {
interactionSession, err := h.authorizationService.getInteractionSession(ctx, input.ID)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
return &httpapi.BodyOutput[interactionSessionForUser]{Body: interactionSession}, nil
}
c.JSON(http.StatusOK, response)
func (h *authorizationHandler) completeInteraction(ctx context.Context, input *completeInteractionInput) (*httpapi.BodyOutput[completeInteractionResponse], error) {
reauthenticationToken := ""
if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil {
reauthenticationToken = requestCookie.Value
}
response, err := h.authorizationService.completeInteractionStep(ctx, input.ID, httpapi.UserID(ctx), input.Body.Step, reauthenticationToken, httpapi.AuthenticationTime(ctx), requestMetaFromContext(ctx))
if err != nil {
return nil, err
}
return &httpapi.BodyOutput[completeInteractionResponse]{Body: response}, nil
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
@@ -169,6 +165,10 @@ func requestMetaFromGin(c *gin.Context) requestMeta {
}
}
func requestMetaFromContext(ctx context.Context) requestMeta {
return requestMeta{IPAddress: httpapi.ClientIP(ctx), UserAgent: httpapi.UserAgent(ctx)}
}
func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string {
params := make(map[string]string)
for key, values := range requester.GetRequestForm() {

View File

@@ -1,15 +1,17 @@
package oidc
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type deviceHandler struct {
@@ -38,50 +40,38 @@ func (h *deviceHandler) authorizeDevice(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
type deviceCodeInput struct {
Code string `query:"code" required:"true"`
}
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
func (h *deviceHandler) verifyDeviceCode(ctx context.Context, input *deviceCodeInput) (*httpapi.EmptyOutput, error) {
reauthenticationToken := ""
if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil {
reauthenticationToken = requestCookie.Value
}
err := h.deviceService.acceptDeviceCode(
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
typedAuthenticationTime,
ctx,
input.Code,
httpapi.UserID(ctx),
httpapi.AuthenticationMethod(ctx),
httpapi.AuthenticationTime(ctx),
reauthenticationToken,
requestMetaFromGin(c),
requestMetaFromContext(ctx),
)
if err != nil {
if errors.Is(err, fosite.ErrAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
return
return nil, &common.OidcAccessDeniedError{}
}
_ = c.Error(err)
return
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
func (h *deviceHandler) deviceCodeInfo(ctx context.Context, input *deviceCodeInput) (*httpapi.BodyOutput[dto.DeviceCodeInfoDto], error) {
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(ctx, input.Code, httpapi.UserID(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.JSON(http.StatusOK, deviceCodeInfo)
return &httpapi.BodyOutput[dto.DeviceCodeInfoDto]{Body: *deviceCodeInfo}, nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/ory/fosite"
@@ -220,8 +219,6 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
}
func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
userCode = strings.ToUpper(strings.TrimSpace(userCode))
userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
if err != nil {
return nil, "", err

View File

@@ -61,14 +61,6 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t
require.Equal(t, 1, reauth.calls)
}
func TestDeviceServiceCreatesUserCodeWithOAuthPrefix(t *testing.T) {
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, "test-client", "test-user", false, nil)
require.Regexp(t, `^E[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$`, userCode)
_, _, err := service.deviceRequestFromUserCode(t.Context(), strings.ToLower(userCode))
require.NoError(t, err)
}
func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
const (
userID = "test-user"

View File

@@ -1,32 +0,0 @@
package oidc
import (
"context"
"github.com/ory/fosite/handler/rfc8628"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
oauthDeviceUserCodePrefix = "E"
oauthDeviceUserCodeRandomLength = 7
)
type deviceStrategy struct {
*rfc8628.DefaultDeviceStrategy
}
func (s *deviceStrategy) GenerateUserCode(ctx context.Context) (string, string, error) {
userCode, err := utils.GenerateRandomUppercaseUnambiguousString(oauthDeviceUserCodeRandomLength)
if err != nil {
return "", "", err
}
userCode = oauthDeviceUserCodePrefix + userCode
signature, err := s.UserCodeSignature(ctx, userCode)
if err != nil {
return "", "", err
}
return userCode, signature, nil
}

View File

@@ -35,7 +35,7 @@ func (h *endSessionHandler) endSession(c *gin.Context) {
return
}
cookie.AddAccessTokenCookie(c, 0, "")
http.SetCookie(c.Writer, cookie.NewAccessTokenCookie(-1, ""))
if callbackURL == "" {
c.Redirect(http.StatusFound, h.baseURL+"/logout")
return

View File

@@ -6,9 +6,11 @@ import (
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
"gorm.io/gorm"
)
@@ -98,26 +100,139 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
}, nil
}
func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
// RegisterRawRoutes mounts protocol endpoints that must retain direct Gin and Fosite response control
func (m *Module) RegisterRawRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, api huma.API) {
rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction)
apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
apiGroup.POST("/oidc/token", m.tokenHandler.token)
apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo)
apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo)
apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken)
apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode)
apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "authorize-get",
Method: http.MethodGet,
Path: "/authorize",
Summary: "Authorize",
Tags: []string{"OIDC Protocol"},
}, http.StatusOK, http.StatusFound)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "authorize-post",
Method: http.MethodPost,
Path: "/authorize",
Summary: "Authorize",
Tags: []string{"OIDC Protocol"},
}, http.StatusOK, http.StatusFound)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "pushed-authorization-request",
Method: http.MethodPost,
Path: "/api/oidc/par",
Summary: "Create pushed authorization request",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCClientBasic": {}}},
})
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-token",
Method: http.MethodPost,
Path: "/api/oidc/token",
Summary: "Exchange an OIDC token",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCClientBasic": {}}},
})
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-userinfo-get",
Method: http.MethodGet,
Path: "/api/oidc/userinfo",
Summary: "Get OIDC user info",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCAccessToken": {}}},
})
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-userinfo-post",
Method: http.MethodPost,
Path: "/api/oidc/userinfo",
Summary: "Get OIDC user info",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCAccessToken": {}}},
})
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-introspection",
Method: http.MethodPost,
Path: "/api/oidc/introspect",
Summary: "Introspect an OIDC token",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCClientBasic": {}}},
})
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-end-session-get",
Method: http.MethodGet,
Path: "/api/oidc/end-session",
Summary: "End an OIDC session",
Tags: []string{"OIDC Protocol"},
}, http.StatusFound)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-end-session-post",
Method: http.MethodPost,
Path: "/api/oidc/end-session",
Summary: "End an OIDC session",
Tags: []string{"OIDC Protocol"},
}, http.StatusFound)
httpapi.AddRawOperation(api, huma.Operation{
OperationID: "oidc-device-authorization",
Method: http.MethodPost,
Path: "/api/oidc/device/authorize",
Summary: "Create device authorization",
Tags: []string{"OIDC Protocol"},
Security: []map[string][]string{{"OIDCClientBasic": {}}},
})
}
// RegisterTypedRoutes mounts JSON interaction and device verification endpoints
func (m *Module) RegisterTypedRoutes(api huma.API, browserAuth func(*huma.Operation)) {
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-interaction",
Method: http.MethodGet,
Path: "/api/oidc/interactions/{id}",
Summary: "Get OIDC interaction",
Tags: []string{"OIDC Interactions"},
}, m.authorizationHandler.getInteractionSession)
httpapi.Register(api, huma.Operation{
OperationID: "complete-oidc-interaction",
Method: http.MethodPost,
Path: "/api/oidc/interactions/{id}/complete",
Summary: "Complete OIDC interaction",
Tags: []string{"OIDC Interactions"},
}, m.authorizationHandler.completeInteraction, browserAuth)
httpapi.Register(api, huma.Operation{
OperationID: "verify-oidc-device-code",
Method: http.MethodPost,
Path: "/api/oidc/device/verify",
Summary: "Verify OIDC device code",
Tags: []string{"OIDC Protocol"},
DefaultStatus: http.StatusNoContent,
}, m.deviceHandler.verifyDeviceCode, browserAuth)
httpapi.Register(api, huma.Operation{
OperationID: "get-oidc-device-info",
Method: http.MethodGet,
Path: "/api/oidc/device/info",
Summary: "Get OIDC device code info",
Tags: []string{"OIDC Protocol"},
}, m.deviceHandler.deviceCodeInfo, browserAuth)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/ory/fosite/compose"
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/handler/rfc8628"
"github.com/ory/fosite/token/jwt"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/crypto/hkdf"
@@ -19,7 +20,7 @@ import (
type oidcProvider struct {
fosite.OAuth2Provider
deviceStrategy *deviceStrategy
deviceStrategy *rfc8628.DefaultDeviceStrategy
tokenStrategies
}
@@ -63,7 +64,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
}
sig := newJWTSigner(keyGetter)
coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
deviceStrategy := &deviceStrategy{DefaultDeviceStrategy: compose.NewDeviceStrategy(fositeConfig)}
deviceStrategy := compose.NewDeviceStrategy(fositeConfig)
accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{
Signer: sig,
HMACSHAStrategy: coreStrategy,

View File

@@ -106,13 +106,13 @@ func (s *OidcService) ListClients(ctx context.Context, name string, listRequestO
}
// As allowedUserGroupsCount is not a column, we need to manually sort it
if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
if listRequestOptions.SortColumn == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.SortDirection)
response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
response, err := utils.Paginate(listRequestOptions.Page, listRequestOptions.Limit, query, &clients)
return clients, response, err
}
@@ -211,6 +211,8 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
}
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
dto.Normalize(input)
// Base fields
client.Name = input.Name
client.Description = input.Description
@@ -612,10 +614,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
if listRequestOptions.SortColumn == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.SortDirection + " NULLS LAST")
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)

View File

@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/unicode/norm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -457,10 +458,11 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
description := "A test client description"
name := norm.NFD.String("Tést Client")
description := norm.NFD.String("A café client description")
input := dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Test Client",
Name: name,
Description: description,
CallbackURLs: []string{"https://example.com/callback"},
},
@@ -473,7 +475,8 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotEmpty(t, fetched.Description)
assert.Equal(t, description, fetched.Description)
assert.Equal(t, norm.NFC.String(name), fetched.Name)
assert.Equal(t, norm.NFC.String(description), fetched.Description)
}
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
@@ -513,7 +516,7 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
require.NoError(t, err)
// Update with a description
description := "Updated description"
description := norm.NFD.String("Updated café description")
input := dto.OidcClientUpdateDto{
Name: "Test Client",
Description: description,
@@ -527,7 +530,7 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotEmpty(t, fetched.Description)
assert.Equal(t, description, fetched.Description)
assert.Equal(t, norm.NFC.String(description), fetched.Description)
// Update to clear the description
input.Description = ""

View File

@@ -35,11 +35,11 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
}
// As userCount is not a column we need to manually sort it
if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
if listRequestOptions.SortColumn == "userCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.SortDirection)
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)

View File

@@ -1,23 +1,31 @@
package usersignup
import (
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type signUpDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"`
LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"`
Token string `json:"token" required:"false"`
}
func (d *signupTokenCreateDto) Resolve(huma.Context) []error {
if dto.ValidateTTL(d.TTL) {
return nil
}
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
}
type signupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
TTL utils.JSONDuration `json:"ttl" required:"true"`
UsageLimit int `json:"usageLimit" required:"true" minimum:"1" maximum:"100"`
UserGroupIDs []string `json:"userGroupIds" required:"false"`
}
type signupTokenDto struct {

View File

@@ -1,19 +1,36 @@
package usersignup
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
const defaultSignupTokenDuration = time.Hour
type userOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
Body dto.UserDto
}
type signupInput struct {
Body signUpDto
}
type tokenCreateInput struct {
Body signupTokenCreateDto
}
type tokenIDInput struct {
ID string `path:"id"`
}
type handler struct {
service *Service
appConfig AppConfigProvider
@@ -23,175 +40,73 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
func (h *handler) checkInitialAdminSetupAvailable(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
return nil, &common.SetupNotAvailableError{}
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body signUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (h *handler) signUpInitialAdmin(c *gin.Context) {
var input signUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input)
func (h *handler) signUpInitialAdmin(ctx context.Context, input *signupInput) (*userOutput, error) {
user, token, err := h.service.SignUpInitialAdmin(ctx, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return h.userOutput(user, token)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body signupTokenCreateDto true "Signup token information"
// @Success 201 {object} signupTokenDto
// @Router /api/signup-tokens [post]
func (h *handler) createSignupToken(c *gin.Context) {
var input signupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
func (h *handler) createSignupToken(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[signupTokenDto], error) {
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
token, err := h.service.CreateSignupToken(ctx, ttl, input.Body.UsageLimit, input.Body.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var tokenDto signupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
var output signupTokenDto
if err := dto.MapStruct(token, &output); err != nil {
return nil, err
}
c.JSON(http.StatusCreated, tokenDto)
return &httpapi.BodyOutput[signupTokenDto]{Body: output}, nil
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[signupTokenDto]
// @Router /api/signup-tokens [get]
func (h *handler) listSignupTokens(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
func (h *handler) listSignupTokens(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[signupTokenDto]], error) {
tokens, pagination, err := h.service.ListSignupTokens(ctx, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var tokensDto []signupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
var output []signupTokenDto
if err := dto.MapStructList(tokens, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[signupTokenDto]]{Body: dto.Paginated[signupTokenDto]{Data: output, Pagination: pagination}}, nil
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (h *handler) deleteSignupToken(c *gin.Context) {
tokenID := c.Param("id")
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
func (h *handler) deleteSignupToken(ctx context.Context, input *tokenIDInput) (*httpapi.EmptyOutput, error) {
if err := h.service.DeleteSignupToken(ctx, input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// signupHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body signUpDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/signup [post]
func (h *handler) signup(c *gin.Context) {
var input signUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent)
func (h *handler) signup(ctx context.Context, input *signupInput) (*userOutput, error) {
user, accessToken, err := h.service.SignUp(ctx, input.Body, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
return h.userOutput(user, accessToken)
}
func (h *handler) userOutput(user model.User, accessToken string) (*userOutput, error) {
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, accessToken)}, Body: output}, nil
}

View File

@@ -2,12 +2,14 @@ package usersignup
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type TokenService interface {
@@ -50,11 +52,56 @@ func New(deps Dependencies) *Module {
// RegisterRoutes mounts the signup and signup-token management endpoints
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) {
apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken)
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation), signupRateLimit func(huma.Context, func(huma.Context))) {
httpapi.Register(api, huma.Operation{
OperationID: "create-signup-token",
Method: http.MethodPost,
Path: "/api/signup-tokens",
Summary: "Create signup token",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, m.handler.createSignupToken, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "list-signup-tokens",
Method: http.MethodGet,
Path: "/api/signup-tokens",
Summary: "List signup tokens",
Tags: []string{"Users"},
}, m.handler.listSignupTokens, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-signup-token",
Method: http.MethodDelete,
Path: "/api/signup-tokens/{id}",
Summary: "Delete signup token",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, m.handler.deleteSignupToken, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "signup",
Method: http.MethodPost,
Path: "/api/signup",
Summary: "Sign up",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, m.handler.signup, httpapi.WithMiddleware(signupRateLimit))
httpapi.Register(api, huma.Operation{
OperationID: "check-initial-admin-setup",
Method: http.MethodGet,
Path: "/api/signup/setup",
Summary: "Check initial admin setup availability",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, m.handler.checkInitialAdminSetupAvailable)
httpapi.Register(api, huma.Operation{
OperationID: "signup-initial-admin",
Method: http.MethodPost,
Path: "/api/signup/setup",
Summary: "Sign up initial admin user",
Tags: []string{"Users"},
}, m.handler.signUpInitialAdmin)
}

View File

@@ -1,28 +1,35 @@
package cookie
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true)
func NewAccessTokenCookie(maxAgeInSeconds int, token string) *http.Cookie {
return newCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/")
}
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
func NewSessionIDCookie(maxAgeInSeconds int, sessionID string) *http.Cookie {
return newCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/")
}
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
func NewDeviceTokenCookie(deviceToken string) *http.Cookie {
return newCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token")
}
func AddDeviceLoginTokenCookie(c *gin.Context, requestID, deviceToken string) {
path := "/api/device-login/requests/" + requestID + "/exchange"
c.SetCookie(DeviceLoginTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), path, "", true, true)
func NewReauthenticationTokenCookie(reauthenticationToken string) *http.Cookie {
return newCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/")
}
func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) {
c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true)
func newCookie(name, value string, maxAge int, path string) *http.Cookie {
// SameSite remains unset to preserve the cookies emitted by the existing Gin helpers
//nolint:gosec
return &http.Cookie{
Name: name,
Value: value,
Path: path,
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
}
}

View File

@@ -9,7 +9,6 @@ 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
func init() {
@@ -17,7 +16,6 @@ func init() {
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
DeviceTokenCookieName = "device_token"
DeviceLoginTokenCookieName = "device_login_token"
ReauthenticationTokenCookieName = "reauthentication_token"
}
}

View File

@@ -35,6 +35,26 @@ func BuildFormPostCSP(nonce, redirectURI, scriptHash string) string {
return buildCSP(nonce, []string{redirectURI}, []string{scriptHash})
}
// BuildAPIDocsCSP allows the pinned Scalar bundle and the assets it creates
func BuildAPIDocsCSP(nonce string) string {
scriptSrc := "script-src 'self' https://cdn.jsdelivr.net"
if nonce != "" {
scriptSrc += " 'nonce-" + nonce + "'"
}
return "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"img-src * blob: data:; " +
"font-src 'self' https://cdn.jsdelivr.net data:; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
"worker-src blob:; " +
"connect-src 'self'; " +
scriptSrc
}
func buildCSP(nonce string, formActionExtra, scriptSrcExtra []string) string {
formAction := "'self'"
scriptSrc := "script-src 'self'"

View File

@@ -3,17 +3,10 @@ package utils
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// SetCacheControlHeader sets the Cache-Control header for the response.
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
_, ok := ctx.GetQuery("skipCache")
if !ok {
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds)
}
func CacheControlValue(maxAge, staleWhileRevalidate time.Duration) string {
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
return "public, max-age=" + maxAgeSeconds + ", stale-while-revalidate=" + staleWhileRevalidateSeconds
}

View File

@@ -0,0 +1,118 @@
package humautils
import (
"encoding/json"
"io"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
const scalarDocsHTML = `<!doctype html>
<html>
<head>
<title>Pocket ID API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script
id="api-reference"
data-url="/api/openapi.json"
data-configuration='{"theme":"purple","darkMode":true,"layout":"modern","hiddenClients":["unirest"],"defaultHttpClient":{"targetKey":"shell","clientKey":"curl"}}'></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.62.5" integrity="sha384-jVBCKhcCfx34USN27x4iQK1SBNdL/HxKq3KuBAxTS4WPaP5w80K4fjpwB+DezJL5" crossorigin="anonymous"></script>
</body>
</html>`
var ginCompatibleJSONFormat = huma.Format{
Marshal: func(w io.Writer, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
_, err = w.Write(data)
return err
},
Unmarshal: json.Unmarshal,
}
// New creates the Huma API on the existing rate-limited Gin group
func New(r *gin.Engine, group *gin.RouterGroup) huma.API {
config := huma.DefaultConfig("Pocket ID API", common.Version)
config.CreateHooks = nil
config.DocsPath = ""
config.OpenAPIPath = "/api/openapi"
config.SchemasPath = "/api/schemas"
config.AllowAdditionalPropertiesByDefault = true
config.Security = nil
config.Formats = map[string]huma.Format{
"application/json": ginCompatibleJSONFormat,
"json": ginCompatibleJSONFormat,
}
config.DefaultFormat = "application/json"
config.OnAddOperation = append(config.OnAddOperation, rewriteValidationResponse)
if common.EnvConfig.AppURL != "" {
config.Servers = []*huma.Server{{URL: common.EnvConfig.AppURL}}
}
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"BearerAuth": {
Type: "http",
Scheme: "bearer",
BearerFormat: "JWT",
Description: "Pocket ID session JWT sent in the Authorization header",
},
"SessionCookie": {
Type: "apiKey",
In: "cookie",
Name: cookie.AccessTokenCookieName,
Description: "Pocket ID browser session cookie",
},
"ApiKeyAuth": {
Type: "apiKey",
In: "header",
Name: "X-API-Key",
Description: "Pocket ID API key",
},
"OIDCAccessToken": {
Type: "http",
Scheme: "bearer",
Description: "OIDC access token",
},
"OIDCClientBasic": {
Type: "http",
Scheme: "basic",
Description: "OIDC client credentials",
},
}
humagin.MultipartMaxMemory = r.MaxMultipartMemory
api := humagin.NewWithGroup(r, group, config)
api.UseMiddleware(CaptureRequestContext)
registerScalarDocs(group)
return api
}
func rewriteValidationResponse(_ *huma.OpenAPI, operation *huma.Operation) {
response, ok := operation.Responses["422"]
if !ok {
return
}
if _, exists := operation.Responses["400"]; !exists {
operation.Responses["400"] = response
}
delete(operation.Responses, "422")
}
func registerScalarDocs(group *gin.RouterGroup) {
group.GET("/api/docs", func(ctx *gin.Context) {
nonce := utils.GetCSPNonce(ctx)
ctx.Header("Content-Security-Policy", utils.BuildAPIDocsCSP(nonce))
ctx.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarDocsHTML))
})
}

View File

@@ -0,0 +1,237 @@
package humautils
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type testInput struct {
Body struct {
Name string `json:"name" required:"true" minLength:"3"`
}
}
type testOutput struct {
Body map[string]string
}
type testCookieOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
}
type testStreamOutput struct {
ContentType string `header:"Content-Type"`
Body func(huma.Context)
}
type optionalBodyInput struct {
Body *json.RawMessage `required:"false"`
}
type testAppError struct{}
func (testAppError) Error() string { return "test error" }
func (testAppError) Description() string { return "test description" }
func (testAppError) HttpStatusCode() int { return http.StatusConflict }
type trackingReader struct {
io.Reader
closed bool
}
func (r *trackingReader) Close() error {
r.closed = true
return nil
}
func newTestAPI(t *testing.T) (*gin.Engine, huma.API) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
api := New(router, router.Group("/"))
return router, api
}
func TestRequestAndErrorCompatibility(t *testing.T) {
router, api := newTestAPI(t)
Register(api, huma.Operation{OperationID: "test-request", Method: http.MethodPost, Path: "/api/test"}, func(_ context.Context, input *testInput) (*testOutput, error) {
return &testOutput{Body: map[string]string{"name": input.Body.Name}}, nil
})
Register(api, huma.Operation{OperationID: "test-app-error", Method: http.MethodGet, Path: "/api/test-error"}, func(context.Context, *struct{}) (*struct{}, error) {
return nil, testAppError{}
})
Register(api, huma.Operation{OperationID: "test-unknown-error", Method: http.MethodGet, Path: "/api/test-unknown-error"}, func(context.Context, *struct{}) (*struct{}, error) {
return nil, errors.New("private failure")
})
Register(api, huma.Operation{OperationID: "test-optional-body", Method: http.MethodPost, Path: "/api/test-optional-body", DefaultStatus: http.StatusNoContent}, func(_ context.Context, input *optionalBodyInput) (*struct{}, error) {
require.Nil(t, input.Body)
return &struct{}{}, nil
})
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"Pocket ID","unknown":true}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
require.Equal(t, "application/json", response.Header().Get("Content-Type"))
require.JSONEq(t, `{"name":"Pocket ID"}`, response.Body.String())
require.Empty(t, response.Header().Get("Link"))
require.NotContains(t, response.Body.String(), "$schema")
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", nil)
request.Header.Set("Content-Type", "application/json")
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"x"}`))
request.Header.Set("Content-Type", "application/json")
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
require.JSONEq(t, `{"error":"Expected length >= 3"}`, response.Body.String())
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-error", nil))
require.Equal(t, http.StatusConflict, response.Code)
require.JSONEq(t, `{"error":"Test error","error_description":"test description"}`, response.Body.String())
require.Less(t, strings.Index(response.Body.String(), `"error"`), strings.Index(response.Body.String(), `"error_description"`))
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-unknown-error", nil))
require.Equal(t, http.StatusInternalServerError, response.Code)
require.JSONEq(t, `{"error":"Something went wrong"}`, response.Body.String())
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-optional-body", nil))
require.Equal(t, http.StatusNoContent, response.Code)
}
func TestCookiesStreamingAndOpenAPI(t *testing.T) {
router, api := newTestAPI(t)
Register(api, huma.Operation{OperationID: "test-cookies", Method: http.MethodPost, Path: "/api/test-cookies", DefaultStatus: http.StatusNoContent}, func(context.Context, *struct{}) (*testCookieOutput, error) {
return &testCookieOutput{SetCookie: []http.Cookie{{Name: "one", Value: "1"}, {Name: "two", Value: "2"}}}, nil
})
reader := &trackingReader{Reader: strings.NewReader("streamed")}
Register(api, huma.Operation{OperationID: "test-stream", Method: http.MethodGet, Path: "/api/test-stream"}, func(context.Context, *struct{}) (*testStreamOutput, error) {
return &testStreamOutput{ContentType: "text/plain", Body: func(ctx huma.Context) {
defer reader.Close()
_, _ = io.Copy(ctx.BodyWriter(), reader)
}}, nil
})
AddRawOperation(api, huma.Operation{
OperationID: "test-raw",
Method: http.MethodPost,
Path: "/api/test-raw",
Summary: "Raw test",
Tags: []string{"Test"},
})
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-cookies", nil))
require.Equal(t, http.StatusNoContent, response.Code)
require.Equal(t, []string{"one=1", "two=2"}, response.Header().Values("Set-Cookie"))
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-stream", nil))
require.Equal(t, http.StatusOK, response.Code)
require.Equal(t, "text/plain", response.Header().Get("Content-Type"))
require.Equal(t, "streamed", response.Body.String())
require.True(t, reader.closed)
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
require.Equal(t, http.StatusOK, response.Code)
require.Contains(t, response.Body.String(), `"/api/test-raw"`)
require.NotContains(t, response.Body.String(), `"422"`)
require.NotContains(t, response.Body.String(), `"$schema"`)
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
require.Equal(t, http.StatusOK, response.Code)
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
require.Contains(t, response.Header().Get("Content-Security-Policy"), "worker-src blob:")
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
}
func TestRegisterAppliesDecoratorsInOrder(t *testing.T) {
router, api := newTestAPI(t)
var order []string
first := func(operation *huma.Operation) {
operation.Middlewares = append(operation.Middlewares, func(ctx huma.Context, next func(huma.Context)) {
order = append(order, "first")
next(ctx)
})
}
second := func(ctx huma.Context, next func(huma.Context)) {
order = append(order, "second")
next(ctx)
}
Register(api, huma.Operation{
OperationID: "test-decorator-order",
Method: http.MethodGet,
Path: "/api/test-decorator-order",
DefaultStatus: http.StatusNoContent,
}, func(context.Context, *struct{}) (*struct{}, error) {
order = append(order, "handler")
return &struct{}{}, nil
}, first, WithMiddleware(second))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-decorator-order", nil))
require.Equal(t, http.StatusNoContent, response.Code)
require.Equal(t, []string{"first", "second", "handler"}, order)
}
func TestRegisterPreservesBodyLimitConfiguration(t *testing.T) {
router, api := newTestAPI(t)
Register(api, huma.Operation{
OperationID: "test-default-body-limits",
Method: http.MethodPost,
Path: "/api/test-default-body-limits",
}, func(context.Context, *testInput) (*testOutput, error) {
return &testOutput{}, nil
})
defaultOperation := api.OpenAPI().Paths["/api/test-default-body-limits"].Post
require.Equal(t, int64(1<<20), defaultOperation.MaxBodyBytes)
require.Equal(t, 5*time.Second, defaultOperation.BodyReadTimeout)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-default-body-limits", strings.NewReader(`{"name":"`+strings.Repeat("x", 1<<20)+`"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusRequestEntityTooLarge, response.Code)
require.JSONEq(t, `{"error":"Request body is too large limit=1048576 bytes"}`, response.Body.String())
Register(api, huma.Operation{
OperationID: "test-unlimited-body",
Method: http.MethodPost,
Path: "/api/test-unlimited-body",
MaxBodyBytes: -1,
BodyReadTimeout: -1,
}, func(context.Context, *testInput) (*testOutput, error) {
return &testOutput{}, nil
})
unlimitedOperation := api.OpenAPI().Paths["/api/test-unlimited-body"].Post
require.Equal(t, int64(-1), unlimitedOperation.MaxBodyBytes)
require.Equal(t, time.Duration(-1), unlimitedOperation.BodyReadTimeout)
}

View File

@@ -0,0 +1,101 @@
package humautils
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
)
type contextKey uint8
const (
requestContextKey contextKey = iota
clientIPContextKey
userIDContextKey
userIsAdminContextKey
authenticationMethodContextKey
authenticationTimeContextKey
)
// CaptureRequestContext exposes trusted Gin request metadata to typed handlers
func CaptureRequestContext(ctx huma.Context, next func(huma.Context)) {
ginCtx := humagin.Unwrap(ctx)
ctx = huma.WithValue(ctx, requestContextKey, ginCtx.Request)
ctx = huma.WithValue(ctx, clientIPContextKey, ginCtx.ClientIP())
next(ctx)
}
// WithAuthentication adds the authenticated identity to a Huma request context
func WithAuthentication(ctx huma.Context, userID string, isAdmin bool, method string, authenticationTime time.Time) huma.Context {
ctx = huma.WithValue(ctx, userIDContextKey, userID)
ctx = huma.WithValue(ctx, userIsAdminContextKey, isAdmin)
ctx = huma.WithValue(ctx, authenticationMethodContextKey, method)
return huma.WithValue(ctx, authenticationTimeContextKey, authenticationTime)
}
// Request returns the underlying HTTP request for protocol handlers that require it
func Request(ctx context.Context) *http.Request {
request, _ := ctx.Value(requestContextKey).(*http.Request)
return request
}
// ClientIP returns the trusted client IP calculated by Gin
func ClientIP(ctx context.Context) string {
value, _ := ctx.Value(clientIPContextKey).(string)
return value
}
// UserAgent returns the request user agent
func UserAgent(ctx context.Context) string {
request := Request(ctx)
if request == nil {
return ""
}
return request.UserAgent()
}
// Cookie returns a dynamically named request cookie
func Cookie(ctx context.Context, name string) (*http.Cookie, error) {
request := Request(ctx)
if request == nil {
return nil, http.ErrNoCookie
}
return request.Cookie(name)
}
// QueryPresent reports whether a query key was present regardless of its value
func QueryPresent(ctx context.Context, name string) bool {
request := Request(ctx)
if request == nil {
return false
}
_, ok := request.URL.Query()[name]
return ok
}
// UserID returns the authenticated user ID
func UserID(ctx context.Context) string {
value, _ := ctx.Value(userIDContextKey).(string)
return value
}
// IsAdmin reports whether the authenticated user is an administrator
func IsAdmin(ctx context.Context) bool {
value, _ := ctx.Value(userIsAdminContextKey).(bool)
return value
}
// AuthenticationMethod returns the session authentication method
func AuthenticationMethod(ctx context.Context) string {
value, _ := ctx.Value(authenticationMethodContextKey).(string)
return value
}
// AuthenticationTime returns the session authentication time
func AuthenticationTime(ctx context.Context) time.Time {
value, _ := ctx.Value(authenticationTimeContextKey).(time.Time)
return value
}

View File

@@ -0,0 +1,98 @@
package humautils
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"unicode"
"unicode/utf8"
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"gorm.io/gorm"
)
type apiError struct {
status int
Message string `json:"error"`
Description string `json:"error_description,omitempty"`
}
func init() {
huma.NewError = newHumaError
}
func (e *apiError) Error() string { return e.Message }
func (e *apiError) GetStatus() int { return e.status }
func (e *apiError) ContentType(contentType string) string {
if contentType == "application/json" {
return "application/json; charset=utf-8"
}
return contentType
}
func newHumaError(status int, message string, errs ...error) huma.StatusError {
if status == http.StatusUnprocessableEntity {
status = http.StatusBadRequest
}
messages := make([]string, 0, len(errs))
for _, err := range errs {
if err == nil {
continue
}
var detailer huma.ErrorDetailer
if errors.As(err, &detailer) {
messages = append(messages, detailer.ErrorDetail().Message)
continue
}
messages = append(messages, err.Error())
}
if len(messages) > 0 {
message = strings.Join(messages, ", ")
}
return &apiError{status: status, Message: capitalize(message)}
}
func capitalize(message string) string {
if message == "" {
return message
}
r, size := utf8.DecodeRuneInString(message)
return string(unicode.ToUpper(r)) + message[size:]
}
func mapError(ctx context.Context, err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return &apiError{status: http.StatusNotFound, Message: "Record not found"}
}
var appDescriptionError common.AppErrorDescription
if errors.As(err, &appDescriptionError) {
return &apiError{
status: appDescriptionError.HttpStatusCode(),
Message: capitalize(appDescriptionError.Error()),
Description: appDescriptionError.Description(),
}
}
var appError common.AppError
if errors.As(err, &appError) {
return &apiError{status: appError.HttpStatusCode(), Message: capitalize(appError.Error())}
}
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
return &apiError{status: http.StatusRequestEntityTooLarge, Message: "The request body is too large"}
}
slog.ErrorContext(ctx, "Unhandled API error", slog.Any("error", err))
return &apiError{status: http.StatusInternalServerError, Message: "Something went wrong"}
}

View File

@@ -0,0 +1,21 @@
package humautils
import (
"net/http"
"strconv"
"github.com/danielgtaylor/huma/v2"
)
// AddRawOperation documents a Gin endpoint that must retain direct response control
func AddRawOperation(api huma.API, operation huma.Operation, statuses ...int) {
if len(statuses) == 0 {
statuses = []int{http.StatusOK}
}
responses := make(map[string]*huma.Response, len(statuses))
for _, status := range statuses {
responses[strconv.Itoa(status)] = &huma.Response{Description: http.StatusText(status)}
}
operation.Responses = responses
api.OpenAPI().AddOperation(&operation)
}

View File

@@ -0,0 +1,29 @@
package humautils
import (
"context"
"github.com/danielgtaylor/huma/v2"
)
// WithMiddleware appends operation middleware at the point the decorator is applied
func WithMiddleware(middleware func(huma.Context, func(huma.Context))) func(*huma.Operation) {
return func(operation *huma.Operation) {
operation.Middlewares = append(operation.Middlewares, middleware)
}
}
// Register adds a typed operation while preserving Pocket ID error and body-reading behavior
func Register[I, O any](api huma.API, operation huma.Operation, handler func(context.Context, *I) (*O, error), decorators ...func(*huma.Operation)) {
for _, decorator := range decorators {
decorator(&operation)
}
huma.Register(api, operation, func(ctx context.Context, input *I) (*O, error) {
output, err := handler(ctx, input)
if err != nil {
return nil, mapError(ctx, err)
}
return output, nil
})
}

View File

@@ -0,0 +1,19 @@
package humautils
import "github.com/pocket-id/pocket-id/backend/internal/utils"
// EmptyInput represents an operation without path, query, header, or body input
type EmptyInput struct{}
// EmptyOutput represents an operation without a response body or headers
type EmptyOutput struct{}
// BodyOutput wraps a typed response body for Huma
type BodyOutput[T any] struct {
Body T
}
// ListInput exposes the shared list query parameters to Huma
type ListInput struct {
utils.ListRequestOptions
}

View File

@@ -5,13 +5,20 @@ import (
"errors"
"fmt"
"time"
"github.com/danielgtaylor/huma/v2"
)
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
type JSONDuration struct {
type JSONDuration struct { //nolint:recvcheck
time.Duration
}
// Schema documents the string and numeric representations accepted by UnmarshalJSON
func (d JSONDuration) Schema(huma.Registry) *huma.Schema {
return &huma.Schema{OneOf: []*huma.Schema{{Type: huma.TypeString}, {Type: huma.TypeNumber}}}
}
func (d JSONDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}

View File

@@ -1,10 +1,11 @@
package utils
import (
"net/url"
"reflect"
"strings"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -17,15 +18,11 @@ type PaginationResponse struct {
}
type ListRequestOptions struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
Filters map[string][]any
Page int `query:"pagination[page]" required:"false"`
Limit int `query:"pagination[limit]" required:"false"`
SortColumn string `query:"sort[column]" required:"false"`
SortDirection string `query:"sort[direction]" required:"false"`
Filters map[string][]any
}
type FieldMeta struct {
@@ -34,22 +31,19 @@ type FieldMeta struct {
IsFilterable bool
}
func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
return listRequestOptions
}
listRequestOptions.Filters = parseNestedFilters(ctx)
return listRequestOptions
func (options *ListRequestOptions) Resolve(ctx huma.Context) []error {
requestURL := ctx.URL()
options.Filters = parseNestedFilters(requestURL.Query())
return nil
}
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) {
meta := extractModelMetadata(result)
query = applyFilters(params.Filters, query, meta)
query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
query = applySorting(params.SortColumn, params.SortDirection, query, meta)
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
return Paginate(params.Page, params.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) {
@@ -105,9 +99,8 @@ func IsValidSortDirection(direction string) bool {
}
// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
func parseNestedFilters(ctx *gin.Context) map[string][]any {
func parseNestedFilters(query url.Values) map[string][]any {
result := make(map[string][]any)
query := ctx.Request.URL.Query()
for key, values := range query {
if !strings.HasPrefix(key, "filters[") {

View File

@@ -23,12 +23,6 @@ func GenerateRandomUnambiguousString(length int) (string, error) {
return GenerateRandomString(length, charset)
}
// GenerateRandomUppercaseUnambiguousString generates a random uppercase string of the given length using unambiguous characters
func GenerateRandomUppercaseUnambiguousString(length int) (string, error) {
const charset = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
return GenerateRandomString(length, charset)
}
// GenerateRandomString generates a random string of the given length using the provided character set
func GenerateRandomString(length int, charset string) (string, error) {
@@ -36,10 +30,6 @@ func GenerateRandomString(length int, charset string) (string, error) {
return "", errors.New("length must be a positive integer")
}
if len(charset) == 0 {
return "", errors.New("character set must not be empty")
}
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
const (
letterIdxBits = 6 // 6 bits to represent a letter index

View File

@@ -86,20 +86,6 @@ func TestGenerateRandomUnambiguousString(t *testing.T) {
})
}
func TestGenerateRandomUppercaseUnambiguousString(t *testing.T) {
const length = 10
str, err := GenerateRandomUppercaseUnambiguousString(length)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(str) != length {
t.Errorf("Expected length %d, got %d", length, len(str))
}
if !regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`).MatchString(str) {
t.Errorf("String contains lowercase or ambiguous characters: %s", str)
}
}
func TestGenerateRandomString(t *testing.T) {
t.Run("valid length returns characters from charset", func(t *testing.T) {
const length = 20
@@ -133,14 +119,6 @@ func TestGenerateRandomString(t *testing.T) {
t.Error("Expected error for negative length, got nil")
}
})
t.Run("empty charset returns error", func(t *testing.T) {
_, err := GenerateRandomString(10, "")
if err == nil {
t.Error("Expected error for empty charset, got nil")
}
})
}
func TestCapitalizeFirstLetter(t *testing.T) {

View File

@@ -1,16 +1,46 @@
package webauthn
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type emptyOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
}
type bodyOutput[T any] struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
Body T
}
type credentialBodyInput struct {
Body json.RawMessage
}
type optionalCredentialBodyInput struct {
Body *json.RawMessage `required:"false"`
}
type credentialIDInput struct {
ID string `path:"id"`
}
type credentialUpdateInput struct {
ID string `path:"id"`
Body dto.WebauthnCredentialUpdateDto
}
type handler struct {
service *Service
appConfig AppConfigProvider
@@ -20,172 +50,148 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) beginRegistration(c *gin.Context) {
userID := c.GetString("userID")
options, err := h.service.BeginRegistration(c.Request.Context(), userID)
func (h *handler) beginRegistration(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialCreationOptions], error) {
options, err := h.service.BeginRegistration(ctx, httpapi.UserID(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
return &bodyOutput[protocol.PublicKeyCredentialCreationOptions]{
SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)},
Body: options.Response,
}, nil
}
func (h *handler) verifyRegistration(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
func (h *handler) verifyRegistration(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.WebauthnCredentialDto], error) {
sessionID, err := sessionID(ctx)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
return nil, err
}
userID := c.GetString("userID")
credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
request := requestWithBody(ctx, input.Body)
credential, err := h.service.VerifyRegistration(ctx, sessionID, httpapi.UserID(ctx), request, httpapi.ClientIP(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
var output dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, credentialDto)
return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil
}
func (h *handler) beginLogin(c *gin.Context) {
options, err := h.service.BeginLogin(c.Request.Context())
func (h *handler) beginLogin(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialRequestOptions], error) {
options, err := h.service.BeginLogin(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
return &bodyOutput[protocol.PublicKeyCredentialRequestOptions]{
SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)},
Body: options.Response,
}, nil
}
func (h *handler) verifyLogin(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
func (h *handler) verifyLogin(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.UserDto], error) {
sessionID, err := sessionID(ctx)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
return nil, err
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
assertion, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(input.Body))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
user, token, err := h.service.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
user, token, err := h.service.VerifyLogin(ctx, sessionID, assertion, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return &bodyOutput[dto.UserDto]{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
}
func (h *handler) listCredentials(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := h.service.ListCredentials(c.Request.Context(), userID)
func (h *handler) listCredentials(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[[]dto.WebauthnCredentialDto], error) {
credentials, err := h.service.ListCredentials(ctx, httpapi.UserID(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
var output []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, credentialDtos)
return &bodyOutput[[]dto.WebauthnCredentialDto]{Body: output}, nil
}
func (h *handler) deleteCredential(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
func (h *handler) deleteCredential(ctx context.Context, input *credentialIDInput) (*emptyOutput, error) {
userID := httpapi.UserID(ctx)
if err := h.service.DeleteCredential(ctx, userID, input.ID, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx), userID); err != nil {
return nil, err
}
return &emptyOutput{}, nil
}
err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
func (h *handler) updateCredential(ctx context.Context, input *credentialUpdateInput) (*bodyOutput[dto.WebauthnCredentialDto], error) {
credential, err := h.service.UpdateCredential(ctx, httpapi.UserID(ctx), input.ID, input.Body.Name)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
c.Status(http.StatusNoContent)
var output dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &output); err != nil {
return nil, err
}
return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil
}
func (h *handler) updateCredential(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil {
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
func (h *handler) logout(_ context.Context, _ *httpapi.EmptyInput) (*emptyOutput, error) {
return &emptyOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(-1, "")}}, nil
}
func (h *handler) logout(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}
func (h *handler) reauthenticate(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
func (h *handler) reauthenticate(ctx context.Context, input *optionalCredentialBodyInput) (*emptyOutput, error) {
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = h.service.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
var err error
if input.Body != nil {
assertion, parseErr := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(*input.Body))
if parseErr == nil {
sessionCookieID, sessionErr := sessionID(ctx)
if sessionErr != nil {
return nil, sessionErr
}
token, err = h.service.CreateReauthenticationTokenWithWebauthn(ctx, sessionCookieID, assertion)
} else {
token, err = h.reauthenticateWithAccessToken(ctx)
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = h.service.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
token, err = h.reauthenticateWithAccessToken(ctx)
}
cookie.AddReauthenticationTokenCookie(c, token)
c.Status(http.StatusNoContent)
if err != nil {
return nil, err
}
return &emptyOutput{SetCookie: []http.Cookie{*cookie.NewReauthenticationTokenCookie(token)}}, nil
}
func (h *handler) reauthenticateWithAccessToken(ctx context.Context) (string, error) {
accessToken, _ := httpapi.Cookie(ctx, cookie.AccessTokenCookieName)
value := ""
if accessToken != nil {
value = accessToken.Value
}
return h.service.CreateReauthenticationTokenWithAccessToken(ctx, value)
}
func sessionID(ctx context.Context) (string, error) {
id, err := httpapi.Cookie(ctx, cookie.SessionIdCookieName)
if err != nil {
return "", &common.MissingSessionIdError{}
}
return id.Value, nil
}
func requestWithBody(ctx context.Context, body []byte) *http.Request {
request := httpapi.Request(ctx).Clone(ctx)
request.Body = http.NoBody
if len(body) > 0 {
request.Body = io.NopCloser(bytes.NewReader(body))
}
request.ContentLength = int64(len(body))
return request
}

View File

@@ -0,0 +1,109 @@
package webauthn
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestRequestWithBodyReconstructsUnderlyingRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := httpapi.New(router, router.Group("/"))
type input struct {
Body map[string]string
}
type output struct {
Body map[string]string
}
httpapi.Register(api, huma.Operation{
OperationID: "reconstruct-request",
Method: http.MethodPost,
Path: "/api/reconstruct",
}, func(ctx context.Context, _ *input) (*output, error) {
request := requestWithBody(ctx, []byte(`{"credential":"value"}`))
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
require.True(t, bytes.Equal([]byte(`{"credential":"value"}`), body))
require.Equal(t, int64(len(body)), request.ContentLength)
return &output{Body: map[string]string{"status": "ok"}}, nil
})
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/reconstruct", http.NoBody)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/reconstruct", strings.NewReader(`{"input":"present"}`))
request.Header.Set("Content-Type", "application/json")
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
}
func TestLogoutClearsAccessTokenCookie(t *testing.T) {
output, err := (&handler{}).logout(t.Context(), &httpapi.EmptyInput{})
require.NoError(t, err)
require.Len(t, output.SetCookie, 1)
require.Equal(t, -1, output.SetCookie[0].MaxAge)
require.Contains(t, output.SetCookie[0].String(), "Max-Age=0")
}
func TestReauthenticateFallsBackWithoutSessionCookie(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
user := model.User{Base: model.Base{ID: "handler-reauth-user"}, Username: "handler-reauth-user"}
require.NoError(t, db.Create(&user).Error)
signer := newFakeSigner()
accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
router := gin.New()
api := httpapi.New(router, router.Group("/"))
h := &handler{service: &Service{db: db, signer: signer}}
httpapi.Register(api, huma.Operation{
OperationID: "test-reauthenticate-fallback",
Method: http.MethodPost,
Path: "/api/test-reauthenticate-fallback",
DefaultStatus: http.StatusNoContent,
}, h.reauthenticate)
testCases := []struct {
name string
body io.Reader
}{
{name: "empty body"},
{name: "invalid assertion", body: strings.NewReader(`{"invalid":true}`)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-reauthenticate-fallback", testCase.body)
if testCase.body != nil {
request.Header.Set("Content-Type", "application/json")
}
request.AddCookie(cookie.NewAccessTokenCookie(60, accessToken))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusNoContent, response.Code)
require.Contains(t, response.Header().Get("Set-Cookie"), cookie.ReauthenticationTokenCookieName+"=")
})
}
}

View File

@@ -2,13 +2,15 @@ package webauthn
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type TokenService interface {
@@ -53,20 +55,81 @@ func New(deps Dependencies) (*Module, error) {
}
// RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) {
apiGroup.GET("/webauthn/register/start", userAuth, m.handler.beginRegistration)
apiGroup.POST("/webauthn/register/finish", userAuth, m.handler.verifyRegistration)
func (m *Module) RegisterRoutes(api huma.API, userAuth func(*huma.Operation), loginRateLimit, reauthRateLimit func(huma.Context, func(huma.Context))) {
httpapi.Register(api, huma.Operation{
OperationID: "begin-webauthn-registration",
Method: http.MethodGet,
Path: "/api/webauthn/register/start",
Summary: "Begin WebAuthn registration",
Tags: []string{"WebAuthn"},
}, m.handler.beginRegistration, userAuth)
apiGroup.GET("/webauthn/login/start", m.handler.beginLogin)
apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin)
httpapi.Register(api, huma.Operation{
OperationID: "finish-webauthn-registration",
Method: http.MethodPost,
Path: "/api/webauthn/register/finish",
Summary: "Finish WebAuthn registration",
Tags: []string{"WebAuthn"},
}, m.handler.verifyRegistration, userAuth)
apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout)
httpapi.Register(api, huma.Operation{
OperationID: "begin-webauthn-login",
Method: http.MethodGet,
Path: "/api/webauthn/login/start",
Summary: "Begin WebAuthn login",
Tags: []string{"WebAuthn"},
}, m.handler.beginLogin)
apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate)
httpapi.Register(api, huma.Operation{
OperationID: "finish-webauthn-login",
Method: http.MethodPost,
Path: "/api/webauthn/login/finish",
Summary: "Finish WebAuthn login",
Tags: []string{"WebAuthn"},
}, m.handler.verifyLogin, httpapi.WithMiddleware(loginRateLimit))
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)
httpapi.Register(api, huma.Operation{
OperationID: "webauthn-logout",
Method: http.MethodPost,
Path: "/api/webauthn/logout",
Summary: "Log out",
Tags: []string{"WebAuthn"},
DefaultStatus: http.StatusNoContent,
}, m.handler.logout, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "webauthn-reauthenticate",
Method: http.MethodPost,
Path: "/api/webauthn/reauthenticate",
Summary: "Reauthenticate",
Tags: []string{"WebAuthn"},
DefaultStatus: http.StatusNoContent,
}, m.handler.reauthenticate, userAuth, httpapi.WithMiddleware(reauthRateLimit))
httpapi.Register(api, huma.Operation{
OperationID: "list-webauthn-credentials",
Method: http.MethodGet,
Path: "/api/webauthn/credentials",
Summary: "List WebAuthn credentials",
Tags: []string{"WebAuthn"},
}, m.handler.listCredentials, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "update-webauthn-credential",
Method: http.MethodPatch,
Path: "/api/webauthn/credentials/{id}",
Summary: "Update WebAuthn credential",
Tags: []string{"WebAuthn"},
}, m.handler.updateCredential, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "delete-webauthn-credential",
Method: http.MethodDelete,
Path: "/api/webauthn/credentials/{id}",
Summary: "Delete WebAuthn credential",
Tags: []string{"WebAuthn"},
DefaultStatus: http.StatusNoContent,
}, m.handler.deleteCredential, userAuth)
}
// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface

View File

@@ -1 +0,0 @@
DROP TABLE device_login_requests;

View File

@@ -1,14 +0,0 @@
CREATE TABLE device_login_requests
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
code TEXT NOT NULL UNIQUE,
device_token_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')),
expires_at TIMESTAMPTZ NOT NULL,
ip_address INET NOT NULL,
user_agent TEXT NOT NULL,
user_id UUID REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at);

View File

@@ -1 +0,0 @@
DROP TABLE device_login_requests;

View File

@@ -1,20 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE device_login_requests
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL,
code TEXT NOT NULL UNIQUE,
device_token_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')),
expires_at DATETIME NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
user_id TEXT REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -140,7 +140,7 @@
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the <link href='/api/docs'>Pocket ID API</link>.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
@@ -564,20 +564,5 @@
"select_the_permissions_this_client_may_request": "Select the permissions this client may request on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access).",
"i_have_a_longer_code": "I have a longer code",
"pkce_supported_client_title": "This client supports PKCE",
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it.",
"sign_in_with_another_device": "Sign in with another device",
"sign_in_with_another_device_description": "Scan a QR code with a device that has your passkey.",
"creating_device_login_request": "Creating sign-in request...",
"device_login_qr_code": "Device login QR code",
"waiting_for_approval": "Waiting for approval",
"remote_sign_in": "Remote sign in",
"the_requesting_device_has_been_signed_in": "The requesting device has been signed in.",
"the_sign_in_request_was_denied": "The sign-in request was denied.",
"review_the_request_before_approving_it": "Review the requesting device before approving this sign in.",
"sign_in_request": "Sign-in request",
"only_approve_if_you_started_this_sign_in": "Only approve if you started this sign in on the requesting device.",
"deny": "Deny",
"approve": "Approve",
"or": "or",
"visit_and_enter": "Visit {url} and enter:"
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it."
}

View File

@@ -1,67 +1,67 @@
{
"name": "pocket-id-frontend",
"version": "2.11.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "^1.3.0",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.16",
"vite-plugin-compression": "^0.5.1"
}
"name": "pocket-id-frontend",
"version": "2.11.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "^1.3.0",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.16",
"vite-plugin-compression": "^0.5.1"
}
}

View File

@@ -38,13 +38,24 @@
});
const isDesktop = new MediaQuery('(min-width: 1024px)');
let alternativeSignInButton = $derived({
href:
page.url.pathname === '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`,
let alternativeSignInButton = $state({
href: '/login/alternative',
label: m.alternative_sign_in_methods()
});
appConfigStore.subscribe((config) => {
if (config.emailOneTimeAccessAsUnauthenticatedEnabled) {
alternativeSignInButton.href = '/login/alternative';
alternativeSignInButton.label = m.alternative_sign_in_methods();
} else {
alternativeSignInButton.href = '/login/alternative/code';
alternativeSignInButton.label = m.sign_in_with_login_code();
}
if (page.url.pathname != '/login') {
alternativeSignInButton.href = `${alternativeSignInButton.href}?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`;
}
});
</script>
{#if backgroundImageExists === undefined}

View File

@@ -32,14 +32,9 @@
}
};
QRCode.toCanvas(canvasEl, value, options)
.then(() => {
canvasEl?.style.removeProperty('height');
canvasEl?.style.removeProperty('width');
})
.catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
}
});
</script>

View File

@@ -1,28 +0,0 @@
import type {
DeviceLoginDecision,
DeviceLoginExchangeResult,
DeviceLoginRequest,
DeviceLoginVerificationInfo
} from '$lib/types/device-login.type';
import APIService from './api-service';
export default class DeviceLoginService extends APIService {
createRequest = async () => {
const response = await this.api.post('/device-login/requests');
return response.data as DeviceLoginRequest;
};
exchangeRequest = async (requestId: string): Promise<DeviceLoginExchangeResult> => {
const response = await this.api.post(`/device-login/requests/${requestId}/exchange`);
return response.status === 202 ? null : response.data;
};
inspectRequest = async (code: string) => {
const response = await this.api.post('/device-login/verification', { code });
return response.data as DeviceLoginVerificationInfo;
};
decideRequest = async (code: string, decision: DeviceLoginDecision) => {
await this.api.post('/device-login/verification/decision', { code, decision });
};
}

View File

@@ -1,21 +0,0 @@
import type { User } from './user.type';
export type DeviceLoginRequest = {
id: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
expiresAt: string;
interval: number;
};
export type DeviceLoginVerificationInfo = {
userCode: string;
device: string;
ipAddress?: string;
expiresAt: string;
};
export type DeviceLoginDecision = 'approve' | 'deny';
export type DeviceLoginExchangeResult = User | null;

View File

@@ -3,7 +3,6 @@ import { m } from '$lib/paraglide/messages';
export const eventTypes: Record<string, string> = {
SIGN_IN: m.sign_in(),
TOKEN_SIGN_IN: m.token_sign_in(),
REMOTE_SIGN_IN: m.remote_sign_in(),
CLIENT_AUTHORIZATION: m.client_authorization(),
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
ACCOUNT_CREATED: m.account_created(),

View File

@@ -4,15 +4,12 @@
import ScopeList from '$lib/components/scope-list.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as InputOTP from '$lib/components/ui/input-otp';
import { Spinner } from '$lib/components/ui/spinner';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import DeviceLoginService from '$lib/services/device-login-service';
import OIDCService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
@@ -24,24 +21,17 @@
let { data } = $props();
const deviceLoginService = new DeviceLoginService();
const oidcService = new OIDCService();
const webauthnService = new WebAuthnService();
let userCode = $state(data.code || '');
let isLoading = $state(false);
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
let deviceLoginInfo: DeviceLoginVerificationInfo | undefined = $state();
let success = $state(false);
let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state();
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let reauthenticationRequired = $state(false);
let reauthenticated = $state(false);
let normalizedUserCode = $derived(userCode.trim().toUpperCase());
let codeComplete = $derived(normalizedUserCode.length === 8);
let completed = $derived(success || deviceLoginOutcome !== undefined);
onMount(() => {
if (data.code && $userStore) {
@@ -50,29 +40,28 @@
});
async function authorize() {
if (!data.code && !codeComplete) return;
isLoading = true;
errorMessage = null;
try {
await authenticateUserIfNeeded();
let isDeviceLoginCode = normalizedUserCode.startsWith('P');
if (isDeviceLoginCode) {
deviceLoginInfo = await deviceLoginService.inspectRequest(normalizedUserCode);
return;
// Get access token if not signed in
if (!$userStore) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
}
const info = await oidcService.getDeviceCodeInfo(normalizedUserCode);
const info = await oidcService.getDeviceCodeInfo(userCode);
deviceInfo = info;
if (info.authorizationRequired && !authorizationRequired) {
authorizationRequired = true;
isLoading = false;
return;
}
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
reauthenticationRequired = true;
isLoading = false;
return;
}
@@ -81,42 +70,16 @@
reauthenticated = true;
}
await oidcService.verifyDeviceCode(normalizedUserCode);
await oidcService.verifyDeviceCode(userCode);
success = true;
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
} finally {
isLoading = false;
}
}
async function authenticateUserIfNeeded() {
if ($userStore) return;
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
}
async function decideDeviceLogin(decision: 'approve' | 'deny') {
isLoading = true;
deviceLoginDecision = decision;
errorMessage = null;
try {
if (decision === 'approve') {
await reauthenticate();
}
await deviceLoginService.decideRequest(normalizedUserCode, decision);
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
} finally {
isLoading = false;
deviceLoginDecision = undefined;
}
}
async function reauthenticate() {
try {
await webauthnService.reauthenticate();
@@ -126,15 +89,6 @@
await webauthnService.reauthenticate(authResponse);
}
}
function retry() {
errorMessage = null;
if (!deviceLoginInfo) {
deviceInfo = undefined;
authorizationRequired = false;
reauthenticationRequired = false;
}
}
</script>
<svelte:head>
@@ -146,52 +100,16 @@
{#if deviceInfo?.client}
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
{:else}
<LoginLogoErrorSuccessIndicator
success={success || deviceLoginOutcome === 'approved'}
error={!!errorMessage}
/>
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
{/if}
</div>
<h1 class="font-gloock mt-5 text-4xl font-bold">
{m.authorize_device()}
</h1>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}
</p>
{:else if deviceLoginOutcome === 'approved'}
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
{:else if deviceLoginOutcome === 'denied'}
<p class="text-muted-foreground mt-2">{m.the_sign_in_request_was_denied()}</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if deviceLoginInfo}
<p class="text-muted-foreground mt-2">{m.review_the_request_before_approving_it()}</p>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 text-start">
<Card.Content>
<dl class="flex flex-col gap-4 text-sm">
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.code()}</dt>
<dd class="font-medium">
{deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring(
4,
8
)}
</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.device()}</dt>
<dd class="text-right font-medium">{deviceLoginInfo.device}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.ip_address()}</dt>
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
</div>
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
@@ -202,16 +120,16 @@
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 gap-2">
<Card.Header>
<Card.Description class="text-start">
<p class="text-muted-foreground text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
/>
</Card.Description>
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
@@ -220,63 +138,19 @@
</div>
{:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form
id="device-code-form"
onsubmit={preventDefault(authorize)}
class="mt-7 flex w-full max-w-112.5 justify-center"
>
<InputOTP.Root
maxlength={8}
aria-label={m.code()}
bind:value={userCode}
onValueChange={(value) => (userCode = value.toUpperCase())}
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<form id="device-code-form" onsubmit={preventDefault(authorize)} class="w-full max-w-[450px]">
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
</form>
{/if}
{#if !completed}
<div class="mt-10 flex w-full max-w-112.5 gap-2">
{#if errorMessage}
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{:else if deviceLoginInfo}
<Button
class="flex-1"
variant="secondary"
disabled={isLoading}
onclick={() => decideDeviceLogin('deny')}
{#if !success}
<div class="mt-10 flex w-full max-w-[450px] gap-2">
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button form="device-code-form" class="flex-1" onclick={authorize} {isLoading}
>{m.authorize()}</Button
>
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
{m.deny()}
</Button>
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
{m.approve()}
</Button>
{:else}
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
<Button
form="device-code-form"
class="flex-1"
disabled={isLoading || !codeComplete}
onclick={authorize}
>
{#if isLoading}<Spinner data-icon="inline-start" />{/if}
{m.authorize()}
</Button>
<Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if}
</div>
{/if}

View File

@@ -5,12 +5,7 @@
import * as Item from '$lib/components/ui/item/index.js';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import {
LucideChevronRight,
LucideMail,
LucideQrCode,
LucideRectangleEllipsis
} from '@lucide/svelte';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from '@lucide/svelte';
const methods = [
{
@@ -18,12 +13,6 @@
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
},
{
icon: LucideQrCode,
title: m.sign_in_with_another_device(),
description: m.sign_in_with_another_device_description(),
href: '/login/alternative/device'
}
];

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