Compare commits

..

5 Commits

Author SHA1 Message Date
Claude
c572b55598 fix: address CI failures for token actors
- Rename the SQLite up-migration file, which had a malformed name (a stray
  space and a truncated timestamp), so TestMigrationsMatchingVersions can
  parse its version again.
- Suppress a gosec G101 false positive on the signupTokensMigratedKey
  constant (it's a kv key name, not a credential).
- Seed the E2E one-time access tokens through the actor's "restore" method
  instead of writing state directly: when an actor for a token is still
  active from a previous test (e.g. one whose token was already consumed),
  invoking it refreshes its in-memory cache, whereas a direct state write
  left that cache stale and made the re-seeded token look consumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBMz9v9s8S8RPYNWzshkBu
2026-07-22 00:41:30 +00:00
Alessandro (Ale) Segala
282c68a97a Freeze signup tokens into kv and drop the table 2026-07-22 00:24:43 +00:00
Alessandro (Ale) Segala
09c4b116e6 Changes 2026-07-21 22:13:06 +00:00
Alessandro (Ale) Segala
3a18393a4e Merge branch 'main' of https://github.com/pocket-id/pocket-id into actors/tokens 2026-07-21 21:55:59 +00:00
Alessandro (Ale) Segala
a34203fa88 refactor: use actors for one-time access and signup tokens 2026-07-21 03:37:37 +00:00
93 changed files with 10720 additions and 4438 deletions

View File

@@ -11,7 +11,6 @@ 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
@@ -21,6 +20,7 @@ require (
github.com/go-co-op/gocron/v2 v2.22.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
@@ -69,7 +69,7 @@ require (
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/alphadose/haxmap v1.4.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.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
@@ -118,7 +118,6 @@ 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.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
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,8 +82,6 @@ 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=
@@ -523,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.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/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,8 +1,6 @@
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"
)
@@ -25,25 +23,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" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
Resource string `json:"resource" required:"true" maxLength:"350" unorm:"nfc"`
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Resource string `json:"resource" binding:"required,resource_uri,max=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" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
}
type apiPermissionInputDto struct {
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"`
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"`
}
// apiPermissionsUpdateDto replaces the full permission set of an API
type apiPermissionsUpdateDto struct {
Permissions []apiPermissionInputDto `json:"permissions" required:"false"`
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
}
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
@@ -54,28 +52,6 @@ type clientApiAccessDto struct {
}
type clientApiAccessUpdateDto struct {
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
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
}

View File

@@ -1,44 +1,14 @@
package api
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
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
}
@@ -47,93 +17,219 @@ func newHandler(service *Service) *handler {
return &handler{service: service}
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
items := make([]apiResponseDto, len(apis))
for i := range apis {
if err := dto.MapStruct(apis[i], &items[i]); err != nil {
return nil, err
for i, api := range apis {
var item apiResponseDto
if err := dto.MapStruct(api, &item); err != nil {
_ = c.Error(err)
return
}
items[i].Resource = apis[i].Audience
item.Resource = api.Audience
items[i] = item
}
return &httpapi.BodyOutput[dto.Paginated[apiResponseDto]]{Body: dto.Paginated[apiResponseDto]{Data: items, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
Data: items,
Pagination: pagination,
})
}
func (h *handler) get(ctx context.Context, input *idInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Get(ctx, nil, input.ID)
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPI(item)
h.respond(c, http.StatusOK, api)
}
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Create(ctx, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPI(item)
h.respond(c, http.StatusCreated, api)
}
func (h *handler) update(ctx context.Context, input *updateInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.Update(ctx, input.ID, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPI(item)
h.respond(c, http.StatusOK, api)
}
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
// 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
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
func (h *handler) updatePermissions(ctx context.Context, input *permissionsInput) (*httpapi.BodyOutput[apiResponseDto], error) {
item, err := h.service.UpdatePermissions(ctx, input.ID, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPI(item)
h.respond(c, http.StatusOK, api)
}
func (h *handler) getClientAccess(ctx context.Context, input *clientInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
access, err := h.service.GetClientAPIAccess(ctx, input.ClientID)
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(access)}, nil
c.JSON(http.StatusOK, newClientApiAccessDto(access))
}
func (h *handler) updateClientAccess(ctx context.Context, input *clientUpdateInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
applied, err := h.service.SetClientAPIAccess(ctx, input.ClientID, ClientAPIAccess(input.Body))
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(applied)}, nil
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))
}
func newClientAPIAccessDTO(access ClientAPIAccess) clientApiAccessDto {
output := clientApiAccessDto(access)
if output.UserDelegatedPermissionIDs == nil {
output.UserDelegatedPermissionIDs = []string{}
// 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{}
}
if output.ClientPermissionIDs == nil {
output.ClientPermissionIDs = []string{}
if dto.ClientPermissionIDs == nil {
dto.ClientPermissionIDs = []string{}
}
return output
return dto
}
func mapAPI(item API) (*httpapi.BodyOutput[apiResponseDto], error) {
var output apiResponseDto
if err := dto.MapStruct(item, &output); err != nil {
return nil, err
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
}
output.Resource = item.Audience
return &httpapi.BodyOutput[apiResponseDto]{Body: output}, nil
responseDto.Resource = api.Audience
c.JSON(status, responseDto)
}

View File

@@ -2,14 +2,12 @@ package api
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"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 {
@@ -61,70 +59,20 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
}
// RegisterRoutes mounts the admin CRUD endpoints
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)
// 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)
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)
// 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)
}

View File

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

View File

@@ -5,13 +5,13 @@ import (
)
type apiKeyCreateDto struct {
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"`
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"`
}
type apiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type apiKeyDto struct {

View File

@@ -1,25 +1,14 @@
package apikey
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
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
}
@@ -28,46 +17,123 @@ func newHandler(service *Service) *handler {
return &handler{service: service}
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []apiKeyDto
if err := dto.MapStructList(apiKeys, &output); err != nil {
return nil, err
var apiKeysDto []apiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[apiKeyDto]]{Body: dto.Paginated[apiKeyDto]{Data: output, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPIKeyResponse(apiKey, token)
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
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())
// 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())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapAPIKeyResponse(apiKey, token)
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
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
}
return &httpapi.EmptyOutput{}, nil
}
// 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")
func mapAPIKeyResponse(apiKey ApiKey, token string) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
var output apiKeyDto
if err := dto.MapStruct(apiKey, &output); err != nil {
return nil, err
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[apiKeyResponseDto]{Body: apiKeyResponseDto{ApiKey: output, Token: token}}, nil
c.Status(http.StatusNoContent)
}

View File

@@ -2,13 +2,11 @@ package apikey
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"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 {
@@ -35,40 +33,12 @@ 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(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)
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)
}
// ValidateApiKey resolves the user that owns the given raw API key

View File

@@ -98,6 +98,34 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
}
// NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port.
// It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host.
// The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database.
func NewActorStateStore(db *gorm.DB, pg *pgxpool.Pool) (*local.Host, error) {
opts := &NewActorsOpts{DB: db, Postgres: pg}
if pg == nil {
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
opts.SQLite = sqlDB
}
providerOpt, err := opts.getProvider()
if err != nil {
return nil, err
}
return local.NewHost(
// The address is required by the host but never bound, since the host is not Run
local.WithAddress("127.0.0.1:1"),
local.WithLogger(slog.Default().With("scope", "actor-state-store")),
// The health-check deadline only needs to exceed the provider's query timeout to pass validation
local.WithHostHealthCheckDeadline(90*time.Second),
providerOpt,
)
}
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
switch {
case o.Postgres != nil && o.SQLite != nil:

View File

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

View File

@@ -14,7 +14,6 @@ import (
"sync/atomic"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -28,12 +27,11 @@ 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(api huma.API, db *gorm.DB, svc *services)
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
r, err := initEngine()
@@ -145,51 +143,45 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
baseGroup := r.Group("/", rateLimitMiddleware.Add(middleware.RateLimitAPI))
apiGroup := baseGroup.Group("/api")
api := httpapi.New(r, baseGroup)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
svc.apiKeyModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api),
apiGroup := r.Group("/api", apiRateLimitMiddleware)
baseGroup := r.Group("/", apiRateLimitMiddleware)
svc.apiKeyModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
)
svc.webauthnModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
svc.webauthnModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
)
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.appConfigService, 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),
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.oneTimeAccessService, svc.webauthnModule)
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.appConfigService, 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),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)
svc.oidcModule.RegisterTypedRoutes(api, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api))
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
registerTestRoutes(api, db, svc)
registerTestRoutes(apiGroup, db, svc)
controller.NewWellKnownController(api, svc.jwtService)
controller.NewWellKnownController(baseGroup, 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.
@@ -198,13 +190,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
return nil
}
func registerTestRoutes(api huma.API, db *gorm.DB, svc *services) {
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
if common.EnvConfig.AppEnv.IsProduction() {
return
}
for _, f := range registerTestControllers {
f(api, db, svc)
f(apiGroup, db, svc)
}
}

View File

@@ -1,110 +0,0 @@
package bootstrap
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/italypaleale/francis/host/local"
"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)
var services *services
testutils.NewActorHostForTest(t, func(t *testing.T, actors *local.Host) {
services, err = initServices(t.Context(), db, "test-instance", actors, 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/openai.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.StatusNotFound, response.Code)
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

@@ -43,6 +43,7 @@ type services struct {
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
actors *local.Host
}
// Initializes all services
@@ -56,7 +57,9 @@ func initServices(
fileStorage storage.FileStorage,
scheduler *job.Scheduler,
) (svc *services, err error) {
svc = &services{}
svc = &services{
actors: actors,
}
// Init the app config service
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
@@ -132,14 +135,22 @@ func initServices(
return nil, fmt.Errorf("failed to create API key module: %w", err)
}
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
svc.userSignUpModule, err = usersignup.New(ctx, usersignup.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
})
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create user signup module: %w", err)
}
svc.oneTimeAccessService, err = service.NewOneTimeAccessService(actors, db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create one-time access service: %w", err)
}
svc.versionService = service.NewVersionService(httpClient)

View File

@@ -24,57 +24,47 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0]
// Connect to the database
db, _, err := bootstrap.NewDatabase(cmd.Context())
db, pg, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err = db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(txErr, gorm.ErrRecordNotFound):
return errors.New("user not found")
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
err = db.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return errors.New("user not found")
case err != nil:
return fmt.Errorf("failed to query for user: %w", err)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
return nil
})
// One-time access tokens are stored in the actor state store
// The CLI doesn't run the full actor host, so it uses a minimal state store to persist the token directly
actorStore, err := bootstrap.NewActorStateStore(db, pg)
if err != nil {
return err
return fmt.Errorf("failed to initialize the actor state store: %w", err)
}
// Create a new access token that expires in 1 hour
tokenCtx, tokenCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer tokenCancel()
token, _, err := service.StoreOneTimeAccessToken(tokenCtx, actorStore, user.ID, time.Hour, false)
if err != nil {
return fmt.Errorf("failed to create access token: %w", err)
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, token)
return nil
},

View File

@@ -87,14 +87,6 @@ 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{}

View File

@@ -1,77 +1,41 @@
package controller
import (
"context"
"net/http"
"strconv"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/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"
)
type appConfigUpdateInput struct {
Body dto.AppConfigUpdateDto
}
// NewAppConfigController registers application configuration endpoints
// 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
func NewAppConfigController(
api huma.API,
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appConfigService *appconfig.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
controller := &AppConfigController{appConfigService: appConfigService, emailService: emailService, ldapService: ldapService}
auth := authMiddleware.Huma(api)
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)
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-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)
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
}
type AppConfigController struct {
@@ -80,67 +44,143 @@ type AppConfigController struct {
ldapService *service.LdapService
}
func (acc *AppConfigController) listAppConfigHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto], error) {
configuration, err := acc.appConfigService.ListAppConfig(ctx, false)
// 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) {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
configuration := dbConfig.ToAppConfigVariableSlice(false, true)
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
}
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
// 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)
}
func (acc *AppConfigController) listAllAppConfigHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
configuration, err := acc.appConfigService.ListAppConfig(ctx, true)
// 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) {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &output); err != nil {
return nil, err
configuration := dbConfig.ToAppConfigVariableSlice(true, true)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
c.JSON(http.StatusOK, configVariablesDto)
}
func (acc *AppConfigController) updateAppConfigHandler(ctx context.Context, input *appConfigUpdateInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
saved, err := acc.appConfigService.UpdateAppConfig(ctx, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []dto.AppConfigVariableDto
if err := dto.MapStructList(saved, &output); err != nil {
return nil, err
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
c.JSON(http.StatusOK, configVariablesDto)
}
func (acc *AppConfigController) syncLDAPHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
dbConfig, err := acc.appConfigService.GetConfig(ctx)
// 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) {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
if err := acc.ldapService.SyncAll(ctx, dbConfig); err != nil {
return nil, err
err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig)
if err != nil {
_ = c.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
dbConfig, err := acc.appConfigService.GetConfig(ctx)
// 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) {
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
if err := acc.emailService.SendTestEmail(ctx, dbConfig, httpapi.UserID(ctx)); err != nil {
return nil, err
userID := c.GetString("userID")
err = acc.emailService.SendTestEmail(c.Request.Context(), dbConfig, userID)
if err != nil {
_ = c.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}

View File

@@ -1,287 +1,292 @@
package controller
import (
"context"
"io"
"mime/multipart"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/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"
)
type imageGetInput struct {
Light string `query:"light" default:"true" required:"false"`
}
func NewAppImagesController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appImagesService *service.AppImagesService,
) {
controller := &AppImagesController{
appImagesService: appImagesService,
}
type imageUploadForm struct {
File huma.FormFile `form:"file" required:"true"`
}
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 imageUploadInput struct {
RawBody huma.MultipartFormFiles[imageUploadForm]
}
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 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)
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
}
type AppImagesController struct {
appImagesService *service.AppImagesService
}
func (c *AppImagesController) getLogoHandler(ctx context.Context, input *imageGetInput) (*imageOutput, error) {
lightLogo, _ := strconv.ParseBool(input.Light)
// 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"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
return c.getImage(ctx, imageName)
c.getImage(ctx, imageName)
}
func (c *AppImagesController) getEmailLogoHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "logoEmail")
// 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) getBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "background")
// 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) getFaviconHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "favicon")
// 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) getDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
return c.getImage(ctx, "default-profile-picture")
// 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) updateLogoHandler(ctx context.Context, input *logoUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
// 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")
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
lightLogo, _ := strconv.ParseBool(input.Light)
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(ctx, file, imageName); err != nil {
return nil, err
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) updateEmailLogoHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
// 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")
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
mimeType := utils.GetImageMimeType(utils.GetFileExtension(file.Filename))
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType != "image/png" && mimeType != "image/jpeg" {
return nil, &common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
}
return c.updateImage(ctx, file, "logoEmail")
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) updateBackgroundImageHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
// 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")
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
return c.updateImage(ctx, file, "background")
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) updateFaviconHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
if err != nil {
return nil, err
// 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
}
mimeType := utils.GetImageMimeType(strings.ToLower(utils.GetFileExtension(file.Filename)))
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")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
return nil, &common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"}
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
return
}
return c.updateImage(ctx, file, "favicon")
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) updateDefaultProfilePicture(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
return c.updateImage(ctx, file, "default-profile-picture")
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)
}
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)
// 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")
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
cacheControl := ""
if !httpapi.QueryPresent(ctx, "skipCache") {
cacheControl = utils.CacheControlValue(15*time.Minute, 24*time.Hour)
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
return &imageOutput{
ContentType: mimeType,
ContentLength: size,
CacheControl: cacheControl,
Body: func(streamCtx huma.Context) {
defer reader.Close()
_, _ = io.Copy(streamCtx.BodyWriter(), reader)
},
}, nil
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)
}

View File

@@ -1,114 +1,145 @@
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/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// 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)
// 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,
}
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)
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)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
logsDTOs, err := alc.mapAuditLogs(logs, false)
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
// 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,
})
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
logsDTOs, err := alc.mapAuditLogs(logs, true)
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
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,
})
}
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
// 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
}
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
c.JSON(http.StatusOK, names)
}
func (alc *AuditLogController) listClientNamesHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
names, err := alc.auditLogService.ListClientNames(ctx)
// 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())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
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
c.JSON(http.StatusOK, users)
}

View File

@@ -1,91 +1,115 @@
package controller
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"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"
)
type customClaimUserInput struct {
UserID string `path:"userId"`
Body []dto.CustomClaimCreateDto `required:"true"`
}
// 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 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)
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 CustomClaimController struct {
customClaimService *service.CustomClaimService
}
func (ccc *CustomClaimController) getSuggestionsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
claims, err := ccc.customClaimService.GetSuggestions(ctx)
// 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())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[[]string]{Body: claims}, nil
c.JSON(http.StatusOK, claims)
}
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 {
return nil, err
// 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
}
var output []dto.CustomClaimDto
if err := dto.MapStructList(claims, &output); err != nil {
return nil, err
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
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 {
return nil, err
// 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
}
var output []dto.CustomClaimDto
if err := dto.MapStructList(claims, &output); err != nil {
return nil, err
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

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

View File

@@ -1,435 +1,538 @@
package controller
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type oidcClientIDInput struct {
ID string `path:"id"`
}
// 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 oidcClientListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
}
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 oidcClientCreateInput struct {
Body dto.OidcClientCreateDto
}
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
type oidcClientUpdateInput struct {
ID string `path:"id"`
Body dto.OidcClientUpdateDto
}
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 oidcAllowedGroupsInput struct {
ID string `path:"id"`
Body dto.OidcUpdateAllowedUserGroupsDto
}
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
type oidcLogoInput struct {
ID string `path:"id"`
Light string `query:"light" default:"true" required:"false"`
}
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
type oidcLogoUploadInput struct {
ID string `path:"id"`
Light string `query:"light" default:"true" required:"false"`
RawBody huma.MultipartFormFiles[imageUploadForm]
}
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
type oidcUserAuthorizedClientsInput struct {
utils.ListRequestOptions
ID string `path:"id"`
}
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
type oidcOwnAuthorizedClientsInput struct {
utils.ListRequestOptions
}
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
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
}
func (oc *OidcController) getClientMetaDataHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientMetaDataDto], error) {
client, err := oc.oidcService.GetClient(ctx, input.ID)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.OidcClientMetaDataDto
if err := dto.MapStruct(client, &output); err != nil {
return nil, err
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return
}
output.HasDarkLogo = client.HasDarkLogo()
return &httpapi.BodyOutput[dto.OidcClientMetaDataDto]{Body: output}, nil
_ = c.Error(err)
}
func (oc *OidcController) getClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
client, err := oc.oidcService.GetClient(ctx, input.ID)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapOIDCClient(client)
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
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)
// 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)
if err != nil {
return nil, err
_ = 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
// 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[i].HasDarkLogo = clients[i].HasDarkLogo()
output[i].AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(ctx, clients[i].ID)
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
clientsDto[i] = clientDto
}
return &httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]]{Body: dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{Data: output, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto,
Pagination: pagination,
})
}
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))
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapOIDCClient(client)
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
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
}
return &httpapi.EmptyOutput{}, nil
}
func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcClientUpdateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
client, err := oc.oidcService.UpdateClient(ctx, input.ID, input.Body)
// 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 {
return nil, err
_ = c.Error(err)
return
}
return mapOIDCClient(client)
c.Status(http.StatusNoContent)
}
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) {
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"secret": secret}}, nil
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
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)
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
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
c.JSON(http.StatusOK, gin.H{"secret": secret})
}
func (oc *OidcController) updateClientLogoHandler(ctx context.Context, input *oidcLogoUploadInput) (*httpapi.EmptyOutput, error) {
file, err := uploadFile(input.RawBody.Form)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
light, _ := strconv.ParseBool(input.Light)
if err := oc.oidcService.UpdateClientLogo(ctx, input.ID, file, light); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
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)
}
func (oc *OidcController) deleteClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*httpapi.EmptyOutput, error) {
light, _ := strconv.ParseBool(input.Light)
// 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")
if err != nil {
_ = c.Error(err)
return
}
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
}
c.Status(http.StatusNoContent)
}
// 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) {
var err error
if light {
err = oc.oidcService.DeleteClientLogo(ctx, input.ID)
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
if lightLogo {
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
} else {
err = oc.oidcService.DeleteClientDarkLogo(ctx, input.ID)
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
}
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
func (oc *OidcController) updateAllowedUserGroupsHandler(ctx context.Context, input *oidcAllowedGroupsInput) (*httpapi.BodyOutput[dto.OidcClientDto], error) {
client, err := oc.oidcService.UpdateAllowedUserGroups(ctx, input.ID, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.OidcClientDto
if err := dto.MapStruct(client, &output); err != nil {
return nil, err
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
_ = c.Error(err)
return
}
output.HasDarkLogo = client.HasDarkLogo()
return &httpapi.BodyOutput[dto.OidcClientDto]{Body: output}, nil
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
c.JSON(http.StatusOK, oidcClientDto)
}
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)
// 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) listAuthorizedClientsHandler(ctx context.Context, input *oidcUserAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
return oc.listAuthorizedClients(ctx, input.ID, 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) 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)
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(clients, &output); err != nil {
return nil, err
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]]{Body: dto.Paginated[dto.AuthorizedOidcClientDto]{Data: output, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
}
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
}
// 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) 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)
userID := c.GetString("userID")
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]]{Body: dto.Paginated[dto.AccessibleOidcClientDto]{Data: clients, Pagination: pagination}}, nil
c.Status(http.StatusNoContent)
}
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))
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.OidcClientPreviewDto]{Body: *preview}, nil
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
}
func (oc *OidcController) getClientScimServiceProviderHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := oc.oidcService.GetClientScimServiceProvider(ctx, input.ID)
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &output); err != nil {
return nil, err
}
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
c.JSON(http.StatusOK, preview)
}
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
// 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
}
return &httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto]{Body: output}, nil
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, providerDto)
}

View File

@@ -1,108 +1,122 @@
package controller
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"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"
)
type scimIDInput struct {
ID string `path:"id"`
}
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
ugc := ScimController{
scimService: scimService,
}
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)
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 ScimController struct {
scimService *service.ScimService
}
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
}
return &httpapi.EmptyOutput{}, nil
}
func (c *ScimController) createServiceProviderHandler(ctx context.Context, input *scimCreateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := c.scimService.CreateServiceProvider(ctx, &input.Body)
// 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 {
return nil, err
_ = ctx.Error(err)
return
}
return mapSCIMProvider(provider)
ctx.Status(http.StatusOK)
}
func (c *ScimController) updateServiceProviderHandler(ctx context.Context, input *scimUpdateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
provider, err := c.scimService.UpdateServiceProvider(ctx, input.ID, &input.Body)
// 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)
if err != nil {
return nil, err
_ = ctx.Error(err)
return
}
return mapSCIMProvider(provider)
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, providerDTO)
}
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
// 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
}
return &httpapi.EmptyOutput{}, nil
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, providerDTO)
}
func mapSCIMProvider(provider any) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
var output dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &output); err != nil {
return nil, err
// 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
}
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
ctx.Status(http.StatusNoContent)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +1,38 @@
package controller
import (
"context"
"fmt"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/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"
)
type userGroupListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
}
// 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, appConfigService *appconfig.AppConfigService, userGroupService *service.UserGroupService) {
ugc := UserGroupController{
appConfigService: appConfigService,
UserGroupService: userGroupService,
}
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, appConfigService *appconfig.AppConfigService, userGroupService *service.UserGroupService) {
controller := &UserGroupController{appConfigService: appConfigService, 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)
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 UserGroupController struct {
@@ -112,87 +40,227 @@ type UserGroupController struct {
UserGroupService *service.UserGroupService
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
groupsDTO := make([]dto.UserGroupMinimalDto, len(groups))
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
for i, group := range groups {
if err := dto.MapStruct(group, &groupsDTO[i]); err != nil {
return nil, err
var groupDto dto.UserGroupMinimalDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
groupsDTO[i].UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
groupsDto[i] = groupDto
}
return &httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]]{Body: dto.Paginated[dto.UserGroupMinimalDto]{Data: groupsDTO, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
Data: groupsDto,
Pagination: pagination,
})
}
func (ugc *UserGroupController) get(ctx context.Context, input *userGroupIDInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.Get(ctx, input.ID)
// 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"))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapUserGroup(group)
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) create(ctx context.Context, input *userGroupCreateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.Create(ctx, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapUserGroup(group)
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, groupDto)
}
func (ugc *UserGroupController) update(ctx context.Context, input *userGroupUpdateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
dbConfig, err := ugc.appConfigService.GetConfig(ctx)
// 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) {
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
group, err := ugc.UserGroupService.Update(ctx, dbConfig, input.ID, input.Body)
var input dto.UserGroupCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapUserGroup(group)
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) delete(ctx context.Context, input *userGroupIDInput) (*httpapi.EmptyOutput, error) {
dbConfig, err := ugc.appConfigService.GetConfig(ctx)
// 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) {
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
if err := ugc.UserGroupService.Delete(ctx, dbConfig, input.ID); err != nil {
return nil, err
if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil {
_ = c.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return mapUserGroup(group)
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) updateAllowedOIDCClients(ctx context.Context, input *userGroupClientsInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
group, err := ugc.UserGroupService.UpdateAllowedOidcClient(ctx, input.ID, input.Body)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
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
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.UserGroupDto]{Body: output}, nil
c.JSON(http.StatusOK, userGroupDto)
}

View File

@@ -1,63 +1,56 @@
package controller
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/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"
)
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) {
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
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)
group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
}
type VersionController struct {
versionService *service.VersionService
}
func (vc *VersionController) getLatestVersionHandler(ctx context.Context, _ *httpapi.EmptyInput) (*versionOutput, error) {
tag, err := vc.versionService.GetLatestVersion(ctx)
// 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())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
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
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
}
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
// 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,
})
}

View File

@@ -1,52 +1,36 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
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}
// 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}
// Pre-compute the OIDC configuration document, which is static
var err error
controller.oidcConfig, err = controller.computeOIDCConfiguration()
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
}
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)
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
}
type WellKnownController struct {
@@ -54,35 +38,51 @@ type WellKnownController struct {
oidcConfig []byte
}
func (wkc *WellKnownController) jwksHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
// 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) {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: jwks}, nil
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
}
func (wkc *WellKnownController) openIDConfigurationHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: wkc.oidcConfig}, 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) 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,12 +1,5 @@
package dto
import (
"encoding/json"
"net/mail"
"github.com/danielgtaylor/huma/v2"
)
type PublicAppConfigVariableDto struct {
Key string `json:"key"`
Type string `json:"type"`
@@ -19,64 +12,47 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
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
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"`
}

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
package dto
import (
"github.com/danielgtaylor/huma/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
@@ -39,27 +36,27 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientUpdateDto struct {
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"`
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"`
}
type OidcClientCreateDto struct {
OidcClientUpdateDto
ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"`
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
}
type OidcClientCredentialsDto struct {
@@ -67,42 +64,15 @@ type OidcClientCredentialsDto struct {
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer" required:"false"`
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
ReplayProtection bool `json:"replayProtection" required:"false"`
ReplayProtection bool `json:"replayProtection"`
}
type OidcUpdateAllowedUserGroupsDto struct {
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
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
}
type OidcLogoutDto struct {

View File

@@ -1,34 +1,16 @@
package dto
import (
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" required:"false"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" required:"true" format:"email" unorm:"nfc"`
RedirectPath string `json:"redirectPath" required:"false"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
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"}}
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}

View File

@@ -1,10 +1,8 @@
package dto
import (
"net/url"
"time"
"github.com/danielgtaylor/huma/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -18,17 +16,9 @@ type ScimServiceProviderDTO struct {
}
type ScimServiceProviderCreateDTO struct {
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
Endpoint string `json:"endpoint" binding:"required,url"`
Token string `json:"token"`
OidcClientID string `json:"oidcClientId" binding:"required"`
}
type ScimUser struct {

View File

@@ -2,10 +2,8 @@ package dto
import (
"errors"
"net/mail"
"unicode/utf8"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin/binding"
)
type UserDto struct {
@@ -25,57 +23,34 @@ type UserDto struct {
}
type UserCreateDto struct {
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"`
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"`
LdapID string `json:"-"`
}
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
}
//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")
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
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
return e.Struct(u)
}
type EmailVerificationDto struct {
Token string `json:"token" required:"true"`
Token string `json:"token" binding:"required"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" required:"true"`
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}

View File

@@ -63,6 +63,17 @@ 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{
@@ -101,28 +112,3 @@ 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,27 +29,26 @@ type UserGroupMinimalDto struct {
}
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" required:"true"`
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" required:"true" minLength:"2" maxLength:"50" unorm:"nfc"`
Name string `json:"name" required:"true" minLength:"2" maxLength:"255" unorm:"nfc"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
LdapID string `json:"-"`
}
func (g UserGroupCreateDto) Validate() error {
friendlyNameLength := utf8.RuneCountInString(g.FriendlyName)
if friendlyNameLength < 2 || friendlyNameLength > 50 {
return errors.New("friendly name is invalid")
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
nameLength := utf8.RuneCountInString(g.Name)
if nameLength < 2 || nameLength > 255 {
return errors.New("name is invalid")
}
return nil
return e.Struct(g)
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" required:"true"`
UserIDs []string `json:"userIds" binding:"required"`
}

View File

@@ -8,6 +8,9 @@ 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
@@ -18,7 +21,44 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
const maxTTL = 31 * 24 * time.Hour
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())
}
}
}
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
@@ -30,11 +70,6 @@ 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"`
Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" required:"true" minLength:"1" maxLength:"50"`
Name string `json:"name" binding:"required,min=1,max=50"`
}

View File

@@ -15,7 +15,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
@@ -34,8 +33,6 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
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, "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()}),
s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
@@ -61,32 +58,6 @@ func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return nil
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// clearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired signup tokens: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired signup tokens", slog.Int64("count", count))
return nil
}
// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions.
func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error {
count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db)

View File

@@ -2,15 +2,11 @@ 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
@@ -66,7 +62,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,
@@ -79,108 +75,60 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
result, err := m.authenticate(c)
if err != nil {
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{}) {
c.Abort()
_ = c.Error(err)
return
}
if result.UserID != "" {
setGinAuthentication(c, result)
}
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)
if !m.options.AllowApiKeyAuth {
if m.options.SuccessOptional {
c.Next()
return
}
if result.UserID != "" {
ctx = httpapi.WithAuthentication(ctx, result.UserID, result.IsAdmin, result.AuthenticationMethod, result.AuthenticationTime)
c.Abort()
if c.GetHeader("X-API-Key") != "" {
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
return
}
next(ctx)
})
}
}
_ = c.Error(err)
return
}
type authenticationResult struct {
UserID string
IsAdmin bool
AuthenticationMethod string
AuthenticationTime time.Time
}
// 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
}
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
c.Next()
return
}
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
// Both JWT and API key auth failed
c.Abort()
_ = c.Error(err)
}
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,14 +1,12 @@
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"
@@ -20,7 +18,6 @@ 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,41 +88,6 @@ 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,10 +2,13 @@ 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"
)
@@ -26,6 +29,24 @@ 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 {
@@ -68,3 +89,37 @@ 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,8 +4,6 @@ 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"
)
@@ -16,20 +14,6 @@ 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

@@ -1,49 +0,0 @@
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,6 +60,7 @@ 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,8 +10,6 @@ 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"
@@ -88,8 +86,22 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
allowed, retryAfter, err := allowRequest(c.Request.Context(), svc, policy, ip)
// 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)
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
}
@@ -107,53 +119,3 @@ 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,7 +8,6 @@ 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"
@@ -16,7 +15,6 @@ 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"
)
@@ -149,33 +147,3 @@ 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

@@ -1,13 +0,0 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -5,18 +5,12 @@ 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,33 +106,37 @@ func (h *authorizationHandler) authorize(c *gin.Context) {
h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
}
type interactionIDInput struct {
ID string `path:"id"`
}
func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
interactionID := c.Param("id")
type completeInteractionInput struct {
ID string `path:"id"`
Body completeInteractionRequest
}
func (h *authorizationHandler) getInteractionSession(ctx context.Context, input *interactionIDInput) (*httpapi.BodyOutput[interactionSessionForUser], error) {
interactionSession, err := h.authorizationService.getInteractionSession(ctx, input.ID)
interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[interactionSessionForUser]{Body: interactionSession}, nil
c.JSON(http.StatusOK, interactionSession)
}
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
func (h *authorizationHandler) completeInteraction(c *gin.Context) {
interactionID := c.Param("id")
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
var request completeInteractionRequest
if err := c.ShouldBindJSON(&request); err != nil {
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
return
}
response, err := h.authorizationService.completeInteractionStep(ctx, input.ID, httpapi.UserID(ctx), input.Body.Step, reauthenticationToken, httpapi.AuthenticationTime(ctx), requestMetaFromContext(ctx))
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[completeInteractionResponse]{Body: response}, nil
c.JSON(http.StatusOK, response)
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
@@ -165,10 +169,6 @@ 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,17 +1,15 @@
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 {
@@ -40,38 +38,50 @@ func (h *deviceHandler) authorizeDevice(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
type deviceCodeInput struct {
Code string `query:"code" required:"true"`
}
func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
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
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
err := h.deviceService.acceptDeviceCode(
ctx,
input.Code,
httpapi.UserID(ctx),
httpapi.AuthenticationMethod(ctx),
httpapi.AuthenticationTime(ctx),
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
typedAuthenticationTime,
reauthenticationToken,
requestMetaFromContext(ctx),
requestMetaFromGin(c),
)
if err != nil {
if errors.Is(err, fosite.ErrAccessDenied) {
return nil, &common.OidcAccessDeniedError{}
c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
return
}
return nil, err
_ = c.Error(err)
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
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 {
return nil, err
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
return &httpapi.BodyOutput[dto.DeviceCodeInfoDto]{Body: *deviceCodeInfo}, nil
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}

View File

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

View File

@@ -6,11 +6,9 @@ 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"
)
@@ -100,139 +98,26 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
}, nil
}
// 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) {
func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
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)
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)
apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode)
apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/google/uuid"
"github.com/italypaleale/francis/host/local"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -41,6 +42,7 @@ import (
type TestService struct {
db *gorm.DB
actors *local.Host
jwtService *JwtService
appConfigService *appconfig.AppConfigService
ldapService *LdapService
@@ -56,9 +58,10 @@ const (
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
)
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
actors: actors,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
@@ -136,29 +139,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
@@ -282,15 +262,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
}
if err := tx.Create(&accessToken).Error; err != nil {
return err
}
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: datatype.StringList{"openid", "profile", "email"},
@@ -448,53 +419,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
signupTokens := []usersignup.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
UserGroups: []model.UserGroup{
userGroups[0],
},
},
{
Base: model.Base{
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
@@ -541,6 +465,81 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
// One-time access tokens and signup tokens live in the actor state store, so they're seeded separately from the DB transaction above.
err = s.seedOneTimeAccessTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed one-time access tokens: %w", err)
}
err = s.seedSignupTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed signup tokens: %w", err)
}
return nil
}
// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
func (s *TestService) seedSignupTokens(ctx context.Context) error {
now := time.Now().Round(time.Second)
seeds := []usersignup.SignupTokenSeed{
{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Token: "VALID1234567890A",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 0,
UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
CreatedAt: now,
},
{
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
Token: "PARTIAL567890ABC",
ExpiresAt: now.Add(7 * 24 * time.Hour),
UsageLimit: 5,
UsageCount: 2,
CreatedAt: now,
},
{
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
Token: "FULLYUSED567890C",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
CreatedAt: now,
},
}
return usersignup.SeedSignupTokens(ctx, s.actors, seeds)
}
// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
tokens := []struct {
token string
ttl time.Duration
}{
{token: "HPe6k6uiDRRVuAQV", ttl: time.Hour},
{token: "one-time-token", ttl: time.Hour},
}
for _, t := range tokens {
state := oneTimeAccessTokenState{
UserID: e2eRefreshTokenUserID,
ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
}
// Seed through the actor's "restore" method (which sets the state) rather than writing the
// state directly: if an actor for this token is still active from a previous test (for
// example, one whose token was already consumed), invoking it refreshes its in-memory cache
// too, whereas a direct state write would leave that cache stale.
_, err := s.actors.Service().Invoke(ctx, OneTimeAccessTokenActorType, t.token, oneTimeAccessTokenMethodRestore, state)
if err != nil {
return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
}
}
return nil
}

View File

@@ -103,13 +103,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.SortColumn == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
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.SortDirection)
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
response, err := utils.Paginate(listRequestOptions.Page, listRequestOptions.Limit, query, &clients)
response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
return clients, response, err
}
@@ -208,8 +208,6 @@ 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
@@ -611,10 +609,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if listRequestOptions.SortColumn == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
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.SortDirection + " NULLS LAST")
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)

View File

@@ -9,7 +9,6 @@ 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"
@@ -458,11 +457,10 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
name := norm.NFD.String("Tést Client")
description := norm.NFD.String("A café client description")
description := "A test client description"
input := dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: name,
Name: "Test Client",
Description: description,
CallbackURLs: []string{"https://example.com/callback"},
},
@@ -475,8 +473,7 @@ 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, norm.NFC.String(name), fetched.Name)
assert.Equal(t, norm.NFC.String(description), fetched.Description)
assert.Equal(t, description, fetched.Description)
}
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
@@ -516,7 +513,7 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
require.NoError(t, err)
// Update with a description
description := norm.NFD.String("Updated café description")
description := "Updated description"
input := dto.OidcClientUpdateDto{
Name: "Test Client",
Description: description,
@@ -530,7 +527,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, norm.NFC.String(description), fetched.Description)
assert.Equal(t, description, fetched.Description)
// Update to clear the description
input.Description = ""

View File

@@ -0,0 +1,164 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// One-time access tokens are stored entirely in the actor state store.
// Each token is its own actor, whose actor ID is the token value itself.
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job).
// OneTimeAccessTokenActorType is the actor type for the one-time access token actor
const OneTimeAccessTokenActorType = "OneTimeAccessToken"
// Methods exposed by the one-time access token actor
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates and deletes the token), and only afterwards performing the remaining work.
// On failure, the caller compensates by restoring the token via the "restore" method as best-effort.
const (
oneTimeAccessTokenMethodConsume = "consume"
oneTimeAccessTokenMethodRestore = "restore"
)
// oneTimeAccessConsumeStatus is the outcome of a "consume" invocation.
type oneTimeAccessConsumeStatus string
const (
// oneTimeAccessConsumeOK indicates the token was valid and has been consumed
oneTimeAccessConsumeOK oneTimeAccessConsumeStatus = "ok"
// oneTimeAccessConsumeNotFound indicates the token doesn't exist (or has expired)
oneTimeAccessConsumeNotFound oneTimeAccessConsumeStatus = "not_found"
// oneTimeAccessConsumeDeviceMismatch indicates the provided device token doesn't match
oneTimeAccessConsumeDeviceMismatch oneTimeAccessConsumeStatus = "device_mismatch"
)
// oneTimeAccessTokenState is the persisted state of a one-time access token actor
type oneTimeAccessTokenState struct {
UserID string
DeviceToken *string
ExpiresAt time.Time
}
// oneTimeAccessConsumeRequest is the payload for the "consume" method
type oneTimeAccessConsumeRequest struct {
DeviceToken string
}
// oneTimeAccessConsumeResponse is the response of the "consume" method
type oneTimeAccessConsumeResponse struct {
Status oneTimeAccessConsumeStatus
// State is included only when Status is "ok", so the caller can restore it if a later step fails
State oneTimeAccessTokenState
}
// oneTimeAccessTokenActor is the actor that manages a single one-time access token
type oneTimeAccessTokenActor struct {
log *slog.Logger
client actor.Client[oneTimeAccessTokenState]
}
// NewOneTimeAccessTokenActor allocates a new one-time access token actor
// It satisfies actor.Factory
func NewOneTimeAccessTokenActor(actorID string, service *actor.Service) actor.Actor {
return &oneTimeAccessTokenActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", OneTimeAccessTokenActorType),
),
client: actor.NewActorClient[oneTimeAccessTokenState](OneTimeAccessTokenActorType, actorID, service),
}
}
// Invoke implements actor.ActorInvoke
func (a *oneTimeAccessTokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
switch method {
case oneTimeAccessTokenMethodConsume:
return a.consume(parentCtx, data)
case oneTimeAccessTokenMethodRestore:
return nil, a.restore(parentCtx, data)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
// consume atomically validates the token and, if valid, deletes it.
func (a *oneTimeAccessTokenActor) consume(parentCtx context.Context, data actor.Envelope) (oneTimeAccessConsumeResponse, error) {
var req oneTimeAccessConsumeRequest
if data != nil {
err := data.Decode(&req)
if err != nil {
return oneTimeAccessConsumeResponse{}, fmt.Errorf("request body is not valid for method '%s': %w", oneTimeAccessTokenMethodConsume, err)
}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return oneTimeAccessConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
}
// An empty UserID means there's no state: the token doesn't exist (or its state already expired and was purged)
if state.UserID == "" || state.ExpiresAt.Before(time.Now()) {
return oneTimeAccessConsumeResponse{
Status: oneTimeAccessConsumeNotFound,
}, nil
}
// If the token requires a device token, it must match
// A mismatch leaves the token untouched, mirroring the pre-actor behavior
if state.DeviceToken != nil && req.DeviceToken != *state.DeviceToken {
return oneTimeAccessConsumeResponse{
Status: oneTimeAccessConsumeDeviceMismatch,
}, nil
}
// The token is valid: delete the state (one-time use)
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.DeleteState(ctx)
if err != nil {
return oneTimeAccessConsumeResponse{}, fmt.Errorf("error deleting actor state: %w", err)
}
return oneTimeAccessConsumeResponse{
Status: oneTimeAccessConsumeOK,
State: state,
}, nil
}
// restore re-creates the token state, used to compensate when a step after consuming the token fails.
func (a *oneTimeAccessTokenActor) restore(parentCtx context.Context, data actor.Envelope) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", oneTimeAccessTokenMethodRestore)
}
var state oneTimeAccessTokenState
err := data.Decode(&state)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", oneTimeAccessTokenMethodRestore, err)
}
// If the token has meanwhile expired, there's nothing to restore
ttl := time.Until(state.ExpiresAt)
if ttl <= 0 {
return nil
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, &actor.SetStateOpts{
TTL: ttl,
})
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}

View File

@@ -9,32 +9,46 @@ import (
"strings"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OneTimeAccessTokenStore is the minimal interface needed to persist a one-time access token in the actor state store.
// It's satisfied by both *actor.Service (used by the running application) and *local.Host (used by CLI commands, which don't run the full actor host).
type OneTimeAccessTokenStore interface {
SetState(ctx context.Context, actorType string, actorID string, state any, opts *actor.SetStateOpts) error
}
type OneTimeAccessService struct {
db *gorm.DB
actorService *actor.Service
userService *UserService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService {
func NewOneTimeAccessService(actors *local.Host, db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) (*OneTimeAccessService, error) {
err := actors.RegisterActor(OneTimeAccessTokenActorType, NewOneTimeAccessTokenActor)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", OneTimeAccessTokenActorType, err)
}
return &OneTimeAccessService{
db: db,
actorService: actors.Service(),
userService: userService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}, nil
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
@@ -71,12 +85,8 @@ func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ct
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.getUserInternal(ctx, userID, tx)
// Load the user to ensure it exists and has an email address
user, err := s.userService.GetUser(ctx, userID)
if err != nil {
return nil, err
}
@@ -85,11 +95,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
oneTimeAccessToken, deviceToken, err := StoreOneTimeAccessToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
if err != nil {
return nil, err
}
@@ -127,77 +133,79 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Load the user to ensure it exists
_, err = s.userService.getUserInternal(ctx, userID, tx)
_, err = s.userService.GetUser(ctx, userID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
// Create the one-time access token
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
token, _, err = StoreOneTimeAccessToken(ctx, s.actorService, userID, ttl, false)
if err != nil {
return "", err
}
// Commit
err = tx.Commit().Error
if err != nil {
return "", fmt.Errorf("error committing transaction: %w", err)
}
return token, nil
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
// It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite.
res, err := s.actorService.Invoke(ctx, OneTimeAccessTokenActorType, token, oneTimeAccessTokenMethodConsume, oneTimeAccessConsumeRequest{
DeviceToken: deviceToken,
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", fmt.Errorf("error invoking one-time access token actor: %w", err)
}
var consumeRes oneTimeAccessConsumeResponse
err = res.Decode(&consumeRes)
if err != nil {
return model.User{}, "", fmt.Errorf("error decoding one-time access token actor response: %w", err)
}
switch consumeRes.Status {
case oneTimeAccessConsumeNotFound:
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
case oneTimeAccessConsumeDeviceMismatch:
return model.User{}, "", &common.DeviceCodeInvalid{}
case oneTimeAccessConsumeOK:
// All good, continue below
default:
return model.User{}, "", fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
}
// The token has now been consumed. From this point on, if we hit an error we compensate by restoring the token (this is best-effort).
user, accessToken, err := s.completeOneTimeAccessTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent)
if err != nil {
s.restoreOneTimeAccessToken(ctx, token, consumeRes.State)
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
return user, accessToken, nil
}
// completeOneTimeAccessTokenExchange performs the work that follows consuming a token: loading the user, validating it, and issuing an access token.
func (s *OneTimeAccessService) completeOneTimeAccessTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state oneTimeAccessTokenState, ipAddress, userAgent string) (model.User, string, error) {
var user model.User
err := s.db.
WithContext(ctx).
Where("id = ?", state.UserID).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} else if err != nil {
return model.User{}, "", err
}
if oneTimeAccessToken.User.Disabled {
if user.Disabled {
return model.User{}, "", &common.UserDisabledError{}
}
accessToken, err := s.jwtService.GenerateAccessToken(
oneTimeAccessToken.User,
user,
AuthenticationMethodOneTimePassword,
dbConfig.SessionDuration.AsDurationMinutes(),
)
@@ -205,57 +213,73 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, d
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
ipAddress, userAgent,
oneTimeAccessToken.User.ID, model.AuditLogData{},
tx,
user.ID,
model.AuditLogData{},
s.db,
)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
}
return oneTimeAccessToken.User, accessToken, nil
return user, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// restoreOneTimeAccessToken restores a token that was consumed but whose exchange could not be completed.
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that the token was consumed unnecessarily.
func (s *OneTimeAccessService) restoreOneTimeAccessToken(parentCtx context.Context, token string, state oneTimeAccessTokenState) {
// Use a context that is not canceled when the original request ends
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
defer cancel()
_, err := s.actorService.Invoke(ctx, OneTimeAccessTokenActorType, token, oneTimeAccessTokenMethodRestore, state)
if err != nil {
slog.ErrorContext(ctx, "Failed to restore one-time access token after a failed exchange", slog.Any("error", err))
}
}
// StoreOneTimeAccessToken generates a new one-time access token and persists it in the actor state store, with a TTL matching its lifetime.
// It returns the token value and, when requested, the associated device token.
func StoreOneTimeAccessToken(ctx context.Context, store OneTimeAccessTokenStore, userID string, ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
token, deviceToken, err = generateOneTimeAccessToken(ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
now := time.Now().Round(time.Second)
state := oneTimeAccessTokenState{
UserID: userID,
DeviceToken: deviceToken,
ExpiresAt: now.Add(ttl),
}
err = store.SetState(ctx, OneTimeAccessTokenActorType, token, state, &actor.SetStateOpts{TTL: ttl})
if err != nil {
return "", nil, fmt.Errorf("error saving one-time access token state: %w", err)
}
return token, deviceToken, nil
}
// generateOneTimeAccessToken generates the random token value (and optional device token) for a one-time access token.
func generateOneTimeAccessToken(ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
token, err = utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
return "", nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
return "", nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
return token, deviceToken, nil
}

View File

@@ -4,22 +4,111 @@ import (
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// newOneTimeAccessServiceForTest sets up a OneTimeAccessService backed by an in-memory test actor host and returns both.
func newOneTimeAccessServiceForTest(t *testing.T, db *gorm.DB) (*OneTimeAccessService, *local.Host) {
t.Helper()
appConfig := appconfig.NewTestAppConfigService(nil)
instanceID := newInstanceID(t, db)
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig)
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil)
var svc *OneTimeAccessService
host := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
var err error
svc, err = NewOneTimeAccessService(h, db, nil, jwtService, auditLogService, nil)
require.NoError(t, err)
})
require.NotNil(t, svc)
return svc, host
}
func TestExchangeOneTimeAccessTokenSuccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oneTimeAccessService, host := newOneTimeAccessServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "enabled-user"},
Username: "enabled-user",
}
require.NoError(t, db.Create(&user).Error)
token, _, err := StoreOneTimeAccessToken(t.Context(), oneTimeAccessService.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
require.NoError(t, err)
require.Equal(t, user.ID, exchangedUser.ID)
require.NotEmpty(t, accessToken)
// The token must have been consumed
var state oneTimeAccessTokenState
err = host.GetState(t.Context(), OneTimeAccessTokenActorType, token, &state)
require.ErrorIs(t, err, actor.ErrStateNotFound)
// A sign-in audit log must have been created
var auditLogCount int64
require.NoError(t, db.Model(&model.AuditLog{}).
Where("user_id = ? AND event = ?", user.ID, model.AuditLogEventOneTimeAccessTokenSignIn).
Count(&auditLogCount).Error)
require.Equal(t, int64(1), auditLogCount)
}
func TestExchangeOneTimeAccessTokenInvalidToken(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oneTimeAccessService, _ := newOneTimeAccessServiceForTest(t, db)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
}
func TestExchangeOneTimeAccessTokenDeviceMismatch(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oneTimeAccessService, host := newOneTimeAccessServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "device-user"},
Username: "device-user",
}
require.NoError(t, db.Create(&user).Error)
// Store a token that requires a device token
token, deviceToken, err := StoreOneTimeAccessToken(t.Context(), oneTimeAccessService.actorService, user.ID, time.Minute, true)
require.NoError(t, err)
require.NotNil(t, deviceToken)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err = oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
var deviceErr *common.DeviceCodeInvalid
require.ErrorAs(t, err, &deviceErr)
// The token must not have been consumed on a device-token mismatch
var state oneTimeAccessTokenState
err = host.GetState(t.Context(), OneTimeAccessTokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
}
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oneTimeAccessService, host := newOneTimeAccessServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "disabled-user"},
@@ -28,24 +117,23 @@ func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
}
require.NoError(t, db.Create(&user).Error)
loginCode := model.OneTimeAccessToken{
Base: model.Base{ID: "disabled-user-login-code"},
Token: "ABCDEF",
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
UserID: user.ID,
}
require.NoError(t, db.Create(&loginCode).Error)
// Store a one-time access token for the disabled user in the actor state store
token, _, err := StoreOneTimeAccessToken(t.Context(), oneTimeAccessService.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "")
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)
var remainingLoginCode model.OneTimeAccessToken
require.NoError(t, db.Where("token = ?", loginCode.Token).First(&remainingLoginCode).Error)
// The token must have been restored (not consumed), since the exchange failed because the user is disabled
var state oneTimeAccessTokenState
err = host.GetState(t.Context(), OneTimeAccessTokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
var auditLogCount int64
require.NoError(t, db.Model(&model.AuditLog{}).Where("user_id = ?", user.ID).Count(&auditLogCount).Error)

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.SortColumn == "userCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
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.SortDirection)
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)

View File

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

View File

@@ -1,31 +1,23 @@
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" 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"}}
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"`
}
type signupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" required:"true"`
UsageLimit int `json:"usageLimit" required:"true" minimum:"1" maximum:"100"`
UserGroupIDs []string `json:"userGroupIds" required:"false"`
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
}
type signupTokenDto struct {

View File

@@ -1,37 +1,20 @@
package usersignup
import (
"context"
"fmt"
"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/model"
"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 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 AppConfigResolver
@@ -41,84 +24,187 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) checkInitialAdminSetupAvailable(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(ctx)
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
if setupCompleted {
return nil, &common.SetupNotAvailableError{}
_ = c.Error(&common.SetupNotAvailableError{})
return
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
func (h *handler) signUpInitialAdmin(ctx context.Context, input *signupInput) (*userOutput, error) {
config, err := h.appConfig.GetConfig(ctx)
// 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) {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
user, token, err := h.service.SignUpInitialAdmin(ctx, config, input.Body)
if err != nil {
return nil, err
var input signUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input)
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
return h.userOutput(user, token, maxAge)
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (h *handler) createSignupToken(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[signupTokenDto], error) {
ttl := input.Body.TTL.Duration
// 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
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
token, err := h.service.CreateSignupToken(ctx, ttl, input.Body.UsageLimit, input.Body.UserGroupIDs)
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output signupTokenDto
if err := dto.MapStruct(token, &output); err != nil {
return nil, err
var tokenDto signupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[signupTokenDto]{Body: output}, nil
c.JSON(http.StatusCreated, tokenDto)
}
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)
// 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)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []signupTokenDto
if err := dto.MapStructList(tokens, &output); err != nil {
return nil, err
var tokensDto []signupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
return &httpapi.BodyOutput[dto.Paginated[signupTokenDto]]{Body: dto.Paginated[signupTokenDto]{Data: output, Pagination: pagination}}, nil
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
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
// 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
}
return &httpapi.EmptyOutput{}, nil
c.Status(http.StatusNoContent)
}
func (h *handler) signup(ctx context.Context, input *signupInput) (*userOutput, error) {
config, err := h.appConfig.GetConfig(ctx)
// 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) {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
user, accessToken, err := h.service.SignUp(ctx, config, input.Body, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
return nil, err
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(), config, input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
return h.userOutput(user, accessToken, maxAge)
}
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
func (h *handler) userOutput(user model.User, accessToken string, maxAge int) (*userOutput, error) {
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, accessToken)}, Body: output}, nil
c.JSON(http.StatusCreated, userDto)
}

View File

@@ -0,0 +1,74 @@
package usersignup
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// This file holds the one-time migration of the pre-actor signup tokens.
// The "move tokens to actor state" migration freezes the signup_tokens table (and its user-group associations) into a JSON document stored in the "kv" table under the "signup_tokens_migrated" key.
// It's loaded here to seed the singleton signup token actor on first startup.
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
// All timestamps are expressed as Unix seconds.
type migratedSignupToken struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
UserGroupIDs []string `json:"userGroupIds"`
CreatedAt int64 `json:"createdAt"`
}
// loadMigratedSignupTokens reads the signup tokens frozen into the kv table by the migration, so the singleton actor can seed its state from them on first startup
// It returns nil if there's nothing to migrate
func loadMigratedSignupTokens(ctx context.Context, db *gorm.DB) ([]storedSignupToken, error) {
row := model.KV{
Key: signupTokensMigratedKey,
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := db.WithContext(ctx).First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// There are no migrated signup tokens in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated signup tokens from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated signup tokens, nothing to do
return nil, nil
}
var migrated []migratedSignupToken
err = json.Unmarshal([]byte(*row.Value), &migrated)
if err != nil {
return nil, fmt.Errorf("error parsing migrated signup tokens: %w", err)
}
tokens := make([]storedSignupToken, len(migrated))
for i, m := range migrated {
tokens[i] = storedSignupToken{
ID: m.ID,
Token: m.Token,
ExpiresAt: time.Unix(m.ExpiresAt, 0),
UsageLimit: m.UsageLimit,
UsageCount: m.UsageCount,
UserGroupIDs: m.UserGroupIDs,
CreatedAt: time.Unix(m.CreatedAt, 0),
}
}
return tokens, nil
}

View File

@@ -0,0 +1,155 @@
package usersignup
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// versionBeforeMoveTokens is the migration version right before the "move tokens to actor state" migration.
const versionBeforeMoveTokens = 20260718000000
// seedSignupTokensForMigration seeds two signup tokens (one with a user group, one without) into the pre-migration schema.
func seedSignupTokensForMigration(t *testing.T, db *gorm.DB, createdAt, expiresAt time.Time) {
t.Helper()
// An unrelated, non-JSON kv entry, to ensure the freeze/restore queries don't choke on other kv keys
err := db.Exec(
`INSERT INTO kv ("key", "value") VALUES ('instance_id', ?)`,
"not-json-instance-id",
).Error
require.NoError(t, err)
// A user group referenced by one of the tokens
err = db.Exec(
`INSERT INTO user_groups (id, created_at, friendly_name, name) VALUES (?, ?, ?, ?)`,
"grp-1", createdAt.Unix(), "Group One", "group-one",
).Error
require.NoError(t, err)
// A token with a user group
err = db.Exec(
`INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`,
"tok-1", createdAt.Unix(), "TOKENWITHGROUP01", expiresAt.Unix(), 3, 1,
).Error
require.NoError(t, err)
err = db.Exec(
`INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id) VALUES (?, ?)`,
"tok-1", "grp-1",
).Error
require.NoError(t, err)
// A token without user groups
err = db.Exec(
`INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`,
"tok-2", createdAt.Unix(), "TOKENNOGROUP0002", expiresAt.Unix(), 1, 0,
).Error
require.NoError(t, err)
}
func TestLoadMigratedSignupTokens(t *testing.T) {
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) {
seedSignupTokensForMigration(t, db, createdAt, expiresAt)
})
// The migration must have dropped the signup_tokens tables
ok := db.Migrator().HasTable("signup_tokens")
require.False(t, ok, "signup_tokens table should have been dropped")
ok = db.Migrator().HasTable("signup_tokens_user_groups")
require.False(t, ok, "signup_tokens_user_groups table should have been dropped")
tokens, err := loadMigratedSignupTokens(t.Context(), db)
require.NoError(t, err)
require.Len(t, tokens, 2)
byID := make(map[string]storedSignupToken, len(tokens))
for _, tok := range tokens {
byID[tok.ID] = tok
}
tok1 := byID["tok-1"]
require.Equal(t, "TOKENWITHGROUP01", tok1.Token)
require.Equal(t, 3, tok1.UsageLimit)
require.Equal(t, 1, tok1.UsageCount)
require.Equal(t, []string{"grp-1"}, tok1.UserGroupIDs)
require.Equal(t, expiresAt.Unix(), tok1.ExpiresAt.Unix())
require.Equal(t, createdAt.Unix(), tok1.CreatedAt.Unix())
tok2 := byID["tok-2"]
require.Equal(t, "TOKENNOGROUP0002", tok2.Token)
require.Equal(t, 1, tok2.UsageLimit)
require.Equal(t, 0, tok2.UsageCount)
require.Empty(t, tok2.UserGroupIDs)
}
// TestLoadMigratedSignupTokensEmpty verifies that when there were no signup tokens, nothing is frozen and nothing is loaded.
func TestLoadMigratedSignupTokensEmpty(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
tokens, err := loadMigratedSignupTokens(t.Context(), db)
require.NoError(t, err)
require.Empty(t, tokens)
}
// TestMoveTokensToActorStateDown verifies that rolling the migration back recreates the signup token tables and restores their contents from the frozen kv document.
func TestMoveTokensToActorStateDown(t *testing.T) {
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) {
seedSignupTokensForMigration(t, db, createdAt, expiresAt)
})
// The tables were frozen and dropped by the up migration
ok := db.Migrator().HasTable("signup_tokens")
require.False(t, ok)
// Roll the migration back
sqlDB, err := db.DB()
require.NoError(t, err)
m, cleanup, err := utils.GetEmbeddedMigrateInstance(t.Context(), sqlDB)
require.NoError(t, err)
defer cleanup()
err = m.Migrate(versionBeforeMoveTokens)
require.NoError(t, err)
// The tables must have been recreated and repopulated from the frozen document
ok = db.Migrator().HasTable("signup_tokens")
require.True(t, ok)
ok = db.Migrator().HasTable("signup_tokens_user_groups")
require.True(t, ok)
type row struct {
ID string
Token string
UsageLimit int
UsageCount int
}
var rows []row
err = db.Raw(`SELECT id, token, usage_limit, usage_count FROM signup_tokens ORDER BY id`).Scan(&rows).Error
require.NoError(t, err)
require.Equal(t, []row{
{ID: "tok-1", Token: "TOKENWITHGROUP01", UsageLimit: 3, UsageCount: 1},
{ID: "tok-2", Token: "TOKENNOGROUP0002", UsageLimit: 1, UsageCount: 0},
}, rows)
var groupID string
err = db.Raw(`SELECT user_group_id FROM signup_tokens_user_groups WHERE signup_token_id = ?`, "tok-1").Scan(&groupID).Error
require.NoError(t, err)
require.Equal(t, "grp-1", groupID)
// The frozen document must have been removed from the kv table
var kvCount int64
err = db.Raw(`SELECT count(*) FROM kv WHERE "key" = ?`, signupTokensMigratedKey).Scan(&kvCount).Error
require.NoError(t, err)
require.Zero(t, kvCount)
}

View File

@@ -1,8 +1,6 @@
package usersignup
import (
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -15,17 +13,5 @@ type SignupToken struct {
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
}
func (st *SignupToken) IsExpired() bool {
return time.Time(st.ExpiresAt).Before(time.Now())
}
func (st *SignupToken) IsUsageLimitReached() bool {
return st.UsageCount >= st.UsageLimit
}
func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached()
UserGroups []model.UserGroup `json:"userGroups"`
}

View File

@@ -2,16 +2,16 @@ package usersignup
import (
"context"
"net/http"
"fmt"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type TokenService interface {
@@ -32,7 +32,8 @@ type AppConfigResolver interface {
}
type Dependencies struct {
DB *gorm.DB
DB *gorm.DB
Actors *local.Host
Signer TokenService
AuditLog AuditLogger
@@ -45,66 +46,40 @@ type Module struct {
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps)
func New(ctx context.Context, deps Dependencies) (*Module, error) {
// Load the signup tokens frozen into the kv table by the migration, so the singleton actor can be seeded (migrated) from them on first startup
migrated, err := loadMigratedSignupTokens(ctx, deps.DB)
if err != nil {
return nil, err
}
// Register the singleton actor that holds all signup tokens
bootstrapData := &signupTokenBootstrap{
Tokens: migrated,
}
err = deps.Actors.RegisterSingletonActor(
SignupTokenActorType, NewSignupTokenActor,
local.WithBootstrapData(bootstrapData),
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", SignupTokenActorType, err)
}
service := newService(deps, deps.Actors.Service())
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
}
}, nil
}
// 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(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)
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)
}

View File

@@ -2,12 +2,15 @@ package usersignup
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/italypaleale/francis/actor"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -22,57 +25,51 @@ import (
const authenticationMethodOneTimePassword = "otp"
type Service struct {
db *gorm.DB
userCreator UserCreator
signer TokenService
auditLog AuditLogger
db *gorm.DB
actorService *actor.Service
userCreator UserCreator
signer TokenService
auditLog AuditLogger
}
func newService(deps Dependencies) *Service {
func newService(deps Dependencies, actorService *actor.Service) *Service {
return &Service{
db: deps.DB,
userCreator: deps.UserCreator,
signer: deps.Signer,
auditLog: deps.AuditLog,
db: deps.DB,
actorService: actorService,
userCreator: deps.UserCreator,
signer: deps.Signer,
auditLog: deps.AuditLog,
}
}
func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
tokenProvided := signupData.Token != ""
if config.AllowUserSignups.String() != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
}
var signupToken SignupToken
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&signupToken).
Error
// Consume the signup token by invoking its actor: this atomically validates it and increments its usage count
// Note: must invoke outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite
res, err := s.actorService.Invoke(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodConsume, signupTokenConsumeRequest{
Token: signupData.Token,
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
return model.User{}, "", fmt.Errorf("error invoking signup token actor: %w", err)
}
if !signupToken.IsValid() {
var consumeRes signupTokenConsumeResponse
err = res.Decode(&consumeRes)
if err != nil {
return model.User{}, "", fmt.Errorf("error decoding signup token actor response: %w", err)
}
if consumeRes.Status != signupTokenConsumeOK {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
userGroupIDs = consumeRes.UserGroupIDs
}
userToCreate := dto.UserCreateDto{
@@ -85,6 +82,27 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
EmailVerified: config.EmailsVerified.IsTrue(),
}
// The token has now been consumed
// From this point on, if we hit an error we compensate by releasing the token (best-effort)
user, accessToken, err := s.createSignedUpUser(ctx, config, userToCreate, signupData.Token, tokenProvided, ipAddress, userAgent)
if err != nil {
if tokenProvided {
s.releaseSignupToken(ctx, signupData.Token)
}
return model.User{}, "", err
}
return user, accessToken, nil
}
// createSignedUpUser creates the user and issues an access token within a single transaction.
// It performs no actor calls, so it's safe to keep the transaction open for its whole duration.
func (s *Service) createSignedUpUser(ctx context.Context, config *appconfig.AppConfigModel, userToCreate dto.UserCreateDto, token string, tokenProvided bool, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
@@ -97,15 +115,8 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
if tokenProvided {
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"signupToken": signupToken.Token,
"signupToken": token,
}, tx)
signupToken.UsageCount++
err = tx.WithContext(ctx).Save(&signupToken).Error
if err != nil {
return model.User{}, "", err
}
} else {
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"method": "open_signup",
@@ -120,6 +131,21 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
return user, accessToken, nil
}
// releaseSignupToken reverts the usage count increment performed while consuming a token, used to compensate when the signup could not be completed.
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that a token use was consumed unnecessarily
func (s *Service) releaseSignupToken(parentCtx context.Context, token string) {
// Use a context that is not canceled when the original request ends
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
defer cancel()
_, err := s.actorService.Invoke(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodRelease, signupTokenReleaseRequest{
Token: token,
})
if err != nil {
slog.ErrorContext(ctx, "Failed to release signup token after a failed signup", slog.Any("error", err))
}
}
func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppConfigModel, signUpData signUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
@@ -177,55 +203,217 @@ func (s *Service) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB)
}
func (s *Service) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
var tokens []SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&SignupToken{})
// Signup tokens are held in the singleton actor's state, so we retrieve them all via a read-only Peek and then sort and paginate in memory.
res, err := s.actorService.Peek(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodList, nil)
if err != nil {
return nil, utils.PaginationResponse{}, fmt.Errorf("error listing signup tokens from actor: %w", err)
}
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
var listRes signupTokenListResponse
err = res.Decode(&listRes)
if err != nil {
return nil, utils.PaginationResponse{}, fmt.Errorf("error decoding signup token actor response: %w", err)
}
// Resolve the referenced user groups so they can be included in the response
groupsByID, err := s.loadUserGroupsByID(ctx, listRes.Tokens)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
tokens := make([]SignupToken, len(listRes.Tokens))
for i, t := range listRes.Tokens {
tokens[i] = signupTokenModelFromStored(t, resolveUserGroups(t.UserGroupIDs, groupsByID))
}
return paginateSignupTokens(tokens, listRequestOptions)
}
func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
_, err := s.actorService.Invoke(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodDelete, signupTokenDeleteRequest{
ID: tokenID,
})
if err != nil {
return fmt.Errorf("error deleting signup token via actor: %w", err)
}
return nil
}
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
signupToken, err := newSignupToken(ttl, usageLimit)
if err != nil {
return SignupToken{}, err
}
// Load the referenced user groups to validate them and to include them in the response
var userGroups []model.UserGroup
err = s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return SignupToken{}, err
if len(userGroupIDs) > 0 {
err := s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return SignupToken{}, err
}
}
return *signupToken, nil
}
validGroupIDs := make([]string, len(userGroups))
for i, g := range userGroups {
validGroupIDs[i] = g.ID
}
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return SignupToken{}, err
}
now := time.Now().Round(time.Second)
stored := storedSignupToken{
ID: uuid.NewString(),
Token: randomString,
ExpiresAt: now.Add(ttl),
UsageLimit: usageLimit,
UsageCount: 0,
UserGroupIDs: validGroupIDs,
CreatedAt: now,
}
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodCreate, stored)
if err != nil {
return SignupToken{}, fmt.Errorf("error creating signup token via actor: %w", err)
}
return signupTokenModelFromStored(stored, userGroups), nil
}
// loadUserGroupsByID loads every user group referenced by the given tokens, keyed by ID.
func (s *Service) loadUserGroupsByID(ctx context.Context, tokens []storedSignupToken) (map[string]model.UserGroup, error) {
idSet := make(map[string]struct{})
for _, t := range tokens {
for _, id := range t.UserGroupIDs {
idSet[id] = struct{}{}
}
}
if len(idSet) == 0 {
return map[string]model.UserGroup{}, nil
}
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var groups []model.UserGroup
err := s.db.WithContext(ctx).
Where("id IN ?", ids).
Find(&groups).
Error
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
byID := make(map[string]model.UserGroup, len(groups))
for _, g := range groups {
byID[g.ID] = g
}
return token, nil
return byID, nil
}
// resolveUserGroups maps the given group IDs to the corresponding UserGroup objects, preserving order and skipping any that no longer exist.
func resolveUserGroups(ids []string, byID map[string]model.UserGroup) []model.UserGroup {
if len(ids) == 0 {
return nil
}
groups := make([]model.UserGroup, 0, len(ids))
for _, id := range ids {
g, ok := byID[id]
if ok {
groups = append(groups, g)
}
}
return groups
}
// signupTokenModelFromStored builds the API/model representation of a signup token from its stored form.
func signupTokenModelFromStored(t storedSignupToken, groups []model.UserGroup) SignupToken {
return SignupToken{
Base: model.Base{
ID: t.ID,
CreatedAt: datatype.DateTime(t.CreatedAt),
},
Token: t.Token,
ExpiresAt: datatype.DateTime(t.ExpiresAt),
UsageLimit: t.UsageLimit,
UsageCount: t.UsageCount,
UserGroups: groups,
}
}
// paginateSignupTokens sorts and paginates the in-memory list of signup tokens, mirroring the behavior of the DB-backed pagination utility.
func paginateSignupTokens(tokens []SignupToken, params utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
sortSignupTokens(tokens, params.Sort.Column, params.Sort.Direction)
page := max(params.Pagination.Page, 1)
pageSize := params.Pagination.Limit
switch {
case pageSize < 1:
pageSize = 20
case pageSize > 100:
pageSize = 100
}
totalItems := int64(len(tokens))
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
if int64(page) > totalPages {
page = int(totalPages)
}
start := min((page-1)*pageSize, len(tokens))
end := min(start+pageSize, len(tokens))
return tokens[start:end], utils.PaginationResponse{
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, nil
}
// sortSignupTokens sorts the tokens by the given column and direction.
// It defaults to sorting by creation date ascending, matching the DB-backed listing.
func sortSignupTokens(tokens []SignupToken, column, direction string) {
desc := utils.NormalizeSortDirection(direction) == "desc"
less := func(i, j int) bool {
caI := time.Time(tokens[i].CreatedAt)
caJ := time.Time(tokens[j].CreatedAt)
return caI.Before(caJ)
}
switch column {
case "expiresAt":
less = func(i, j int) bool {
eaI := time.Time(tokens[i].ExpiresAt)
eaJ := time.Time(tokens[j].ExpiresAt)
return eaI.Before(eaJ)
}
case "usageLimit":
less = func(i, j int) bool { return tokens[i].UsageLimit < tokens[j].UsageLimit }
case "usageCount":
less = func(i, j int) bool {
return tokens[i].UsageCount < tokens[j].UsageCount
}
case "createdAt", "":
// Use the default comparator (creation date)
default:
// Unknown or non-sortable column: keep the default (creation date) ordering
}
sort.SliceStable(tokens, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}

View File

@@ -0,0 +1,127 @@
package usersignup
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeUserCreator struct {
err error
user model.User
}
func (f fakeUserCreator) CreateUserInternal(_ context.Context, _ *appconfig.AppConfigModel, _ dto.UserCreateDto, _ bool, _ *gorm.DB) (model.User, error) {
if f.err != nil {
return model.User{}, f.err
}
return f.user, nil
}
type fakeSigner struct{}
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
return "access-token", nil
}
type fakeAuditLogger struct{}
func (fakeAuditLogger) Create(_ context.Context, _ model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
return model.AuditLog{}, true
}
func newSignupServiceForTest(t *testing.T, db *gorm.DB, userCreator UserCreator) *Service {
t.Helper()
actorService := newSignupTokenActorService(t, nil)
return newService(Dependencies{
DB: db,
UserCreator: userCreator,
Signer: fakeSigner{},
AuditLog: fakeAuditLogger{},
}, actorService)
}
func signupTokenUsageCount(t *testing.T, svc *Service, tokenID string) int {
t.Helper()
tokens, _, err := svc.ListSignupTokens(t.Context(), listAllOptions())
require.NoError(t, err)
for _, tok := range tokens {
if tok.ID == tokenID {
return tok.UsageCount
}
}
t.Fatalf("signup token %q not found", tokenID)
return 0
}
func TestSignUpConsumesTokenOnSuccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := newSignupServiceForTest(t, db, fakeUserCreator{user: model.User{Base: model.Base{ID: "new-user"}}})
token, err := svc.CreateSignupToken(t.Context(), time.Hour, 2, nil)
require.NoError(t, err)
config := appconfig.NewTestConfig(nil)
user, accessToken, err := svc.SignUp(t.Context(), config, signUpDto{
Username: "newuser",
Token: token.Token,
}, "1.2.3.4", "test-agent")
require.NoError(t, err)
require.Equal(t, "new-user", user.ID)
require.Equal(t, "access-token", accessToken)
// The token's usage count must have been incremented and not rolled back
require.Equal(t, 1, signupTokenUsageCount(t, svc, token.ID))
}
func TestSignUpCompensatesTokenOnFailure(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
boom := errors.New("could not create user")
svc := newSignupServiceForTest(t, db, fakeUserCreator{err: boom})
token, err := svc.CreateSignupToken(t.Context(), time.Hour, 2, nil)
require.NoError(t, err)
config := appconfig.NewTestConfig(nil)
_, _, err = svc.SignUp(t.Context(), config, signUpDto{
Username: "newuser",
Token: token.Token,
}, "1.2.3.4", "test-agent")
require.ErrorIs(t, err, boom)
// The usage count increment must have been compensated (reverted back to 0)
require.Equal(t, 0, signupTokenUsageCount(t, svc, token.ID))
}
func TestSignUpRejectsInvalidToken(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := newSignupServiceForTest(t, db, fakeUserCreator{user: model.User{Base: model.Base{ID: "new-user"}}})
config := appconfig.NewTestConfig(nil)
_, _, err := svc.SignUp(t.Context(), config, signUpDto{
Username: "newuser",
Token: "not-a-real-token",
}, "1.2.3.4", "test-agent")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
}
// listAllOptions returns list options that return every token on a single page.
func listAllOptions() utils.ListRequestOptions {
var opts utils.ListRequestOptions
opts.Pagination.Page = 1
opts.Pagination.Limit = 100
return opts
}

View File

@@ -0,0 +1,496 @@
package usersignup
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Signup tokens are stored in a single singleton actor that holds all of them in its state.
// The actor keeps a single alarm scheduled for the moment the earliest-expiring token expires. When that alarm fires, the actor purges every expired token and, if any tokens remain, reschedules the alarm for the next earliest expiration.
// Because it's a singleton, read-only operations (such as listing tokens) are served via Peek, while mutations (create, delete, consume, release) go through Invoke.
//
// Consuming a token must happen outside of a DB transaction (invoking an actor while a transaction is open would deadlock on SQLite): the caller invokes the actor to atomically increment the usage count, performs the rest of its work in a transaction, and, on failure, compensates by invoking the actor again to decrement the usage count (best-effort).
// SignupTokenActorType is the actor type for the signup token singleton actor
const SignupTokenActorType = "SignupToken"
// cleanupAlarmName is the name of the alarm used to purge expired tokens
const cleanupAlarmName = "cleanup"
// Methods exposed by the signup token actor
const (
signupTokenMethodCreate = "create"
signupTokenMethodDelete = "delete"
signupTokenMethodConsume = "consume"
signupTokenMethodRelease = "release"
signupTokenMethodReplace = "replace"
signupTokenMethodList = "list"
)
// signupTokenConsumeStatus is the outcome of a "consume" invocation.
// It's returned as part of the response payload rather than as a Go error, because errors lose their concrete type when they cross the actor invocation boundary.
type signupTokenConsumeStatus string
const (
signupTokenConsumeOK signupTokenConsumeStatus = "ok"
signupTokenConsumeNotFound signupTokenConsumeStatus = "not_found"
signupTokenConsumeExpired signupTokenConsumeStatus = "expired"
signupTokenConsumeLimitReached signupTokenConsumeStatus = "limit_reached"
)
// storedSignupToken is a single signup token as held in the actor state
type storedSignupToken struct {
ID string
Token string
ExpiresAt time.Time
UsageLimit int
UsageCount int
UserGroupIDs []string
CreatedAt time.Time
}
func (t storedSignupToken) isExpired(now time.Time) bool {
return t.ExpiresAt.Before(now)
}
func (t storedSignupToken) isUsageLimitReached() bool {
return t.UsageCount >= t.UsageLimit
}
// signupTokenActorState is the persisted state of the signup token singleton actor.
// Tokens are keyed by their token value.
type signupTokenActorState struct {
Tokens map[string]storedSignupToken
}
// removeExpired deletes every token that has expired and returns the number removed.
func (s *signupTokenActorState) removeExpired(now time.Time) (removed int) {
for k, t := range s.Tokens {
if t.isExpired(now) {
delete(s.Tokens, k)
removed++
}
}
return removed
}
// earliestExpiration returns the earliest expiration time among all tokens, and whether there's at least one token.
func (s *signupTokenActorState) earliestExpiration() (earliest time.Time, found bool) {
for _, t := range s.Tokens {
if !found || t.ExpiresAt.Before(earliest) {
earliest = t.ExpiresAt
found = true
}
}
return earliest, found
}
// Payloads for the actor methods
type signupTokenBootstrap struct {
Tokens []storedSignupToken
}
type signupTokenDeleteRequest struct {
ID string
}
type signupTokenConsumeRequest struct {
Token string
}
type signupTokenConsumeResponse struct {
Status signupTokenConsumeStatus
UserGroupIDs []string
}
type signupTokenReleaseRequest struct {
Token string
}
type signupTokenReplaceRequest struct {
Tokens []storedSignupToken
}
type signupTokenListResponse struct {
Tokens []storedSignupToken
}
// signupTokenActor is the singleton actor that manages all signup tokens
type signupTokenActor struct {
log *slog.Logger
client actor.Client[*signupTokenActorState]
}
// NewSignupTokenActor allocates a new signup token actor
// It satisfies actor.Factory
func NewSignupTokenActor(actorID string, service *actor.Service) actor.Actor {
return &signupTokenActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", SignupTokenActorType),
slog.String("actorID", actorID),
),
client: actor.NewActorClient[*signupTokenActorState](SignupTokenActorType, actorID, service),
}
}
// Bootstrap implements actor.ActorBootstrapper for the singleton actor.
// On first startup it seeds the state from the tokens migrated from the database
// On subsequent startups it just makes sure the cleanup alarm is scheduled.
func (a *signupTokenActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// If we already have a state, just make sure the cleanup alarm is scheduled and we're done
if state != nil {
return a.scheduleCleanup(parentCtx, state)
}
// Initialize the state, seeding it from the migrated tokens (if any)
state = &signupTokenActorState{
Tokens: map[string]storedSignupToken{},
}
if data != nil {
payload := signupTokenBootstrap{}
err = data.Decode(&payload)
if err != nil {
return fmt.Errorf("request body is not valid for bootstrap: %w", err)
}
for _, t := range payload.Tokens {
state.Tokens[t.Token] = t
}
}
// Don't carry over tokens that have already expired
state.removeExpired(time.Now())
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return a.scheduleCleanup(parentCtx, state)
}
// Peek implements actor.ActorPeek for read-only operations
func (a *signupTokenActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
if method != signupTokenMethodList {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
return signupTokenListResponse{
Tokens: collectTokens(state),
}, nil
}
// Invoke implements actor.ActorInvoke for mutating operations
func (a *signupTokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
switch method {
case signupTokenMethodCreate:
return a.create(parentCtx, data)
case signupTokenMethodDelete:
return nil, a.delete(parentCtx, data)
case signupTokenMethodConsume:
return a.consume(parentCtx, data)
case signupTokenMethodRelease:
return nil, a.release(parentCtx, data)
case signupTokenMethodReplace:
return nil, a.replace(parentCtx, data)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
// Alarm implements actor.ActorAlarm: it purges expired tokens and reschedules the alarm.
func (a *signupTokenActor) Alarm(parentCtx context.Context, name string, data actor.Envelope) error {
if name != cleanupAlarmName {
return common.ErrUnsupportedActorMethod{Method: name}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
if state == nil {
return nil
}
removed := state.removeExpired(time.Now())
if removed > 0 {
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
a.log.InfoContext(parentCtx, "Purged expired signup tokens", slog.Int("count", removed))
}
return a.scheduleCleanup(parentCtx, state)
}
func (a *signupTokenActor) create(parentCtx context.Context, data actor.Envelope) (storedSignupToken, error) {
if data == nil {
return storedSignupToken{}, fmt.Errorf("request body is empty for method '%s'", signupTokenMethodCreate)
}
var token storedSignupToken
err := data.Decode(&token)
if err != nil {
return storedSignupToken{}, fmt.Errorf("request body is not valid for method '%s': %w", signupTokenMethodCreate, err)
}
state, err := a.mustGetState(parentCtx)
if err != nil {
return storedSignupToken{}, err
}
state.Tokens[token.Token] = token
err = a.saveState(parentCtx, state)
if err != nil {
return storedSignupToken{}, err
}
err = a.scheduleCleanup(parentCtx, state)
if err != nil {
return storedSignupToken{}, err
}
return token, nil
}
func (a *signupTokenActor) delete(parentCtx context.Context, data actor.Envelope) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", signupTokenMethodDelete)
}
var req signupTokenDeleteRequest
err := data.Decode(&req)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", signupTokenMethodDelete, err)
}
state, err := a.mustGetState(parentCtx)
if err != nil {
return err
}
// Tokens are keyed by their value, so find the one matching the given ID
var deleted bool
for k, t := range state.Tokens {
if t.ID == req.ID {
delete(state.Tokens, k)
deleted = true
break
}
}
if !deleted {
return nil
}
err = a.saveState(parentCtx, state)
if err != nil {
return err
}
return a.scheduleCleanup(parentCtx, state)
}
func (a *signupTokenActor) consume(parentCtx context.Context, data actor.Envelope) (signupTokenConsumeResponse, error) {
if data == nil {
return signupTokenConsumeResponse{}, fmt.Errorf("request body is empty for method '%s'", signupTokenMethodConsume)
}
var req signupTokenConsumeRequest
err := data.Decode(&req)
if err != nil {
return signupTokenConsumeResponse{}, fmt.Errorf("request body is not valid for method '%s': %w", signupTokenMethodConsume, err)
}
state, err := a.mustGetState(parentCtx)
if err != nil {
return signupTokenConsumeResponse{}, err
}
token, ok := state.Tokens[req.Token]
switch {
case !ok:
return signupTokenConsumeResponse{
Status: signupTokenConsumeNotFound,
}, nil
case token.isExpired(time.Now()):
return signupTokenConsumeResponse{
Status: signupTokenConsumeExpired,
}, nil
case token.isUsageLimitReached():
return signupTokenConsumeResponse{
Status: signupTokenConsumeLimitReached,
}, nil
}
// Atomically consume one use of the token
token.UsageCount++
state.Tokens[req.Token] = token
err = a.saveState(parentCtx, state)
if err != nil {
return signupTokenConsumeResponse{}, err
}
return signupTokenConsumeResponse{
Status: signupTokenConsumeOK,
UserGroupIDs: token.UserGroupIDs,
}, nil
}
func (a *signupTokenActor) release(parentCtx context.Context, data actor.Envelope) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", signupTokenMethodRelease)
}
var req signupTokenReleaseRequest
err := data.Decode(&req)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", signupTokenMethodRelease, err)
}
state, err := a.mustGetState(parentCtx)
if err != nil {
return err
}
token, ok := state.Tokens[req.Token]
if !ok || token.UsageCount <= 0 {
// The token is gone (for example, expired and purged) or was never consumed: nothing to compensate
return nil
}
token.UsageCount--
state.Tokens[req.Token] = token
return a.saveState(parentCtx, state)
}
func (a *signupTokenActor) replace(parentCtx context.Context, data actor.Envelope) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", signupTokenMethodReplace)
}
var req signupTokenReplaceRequest
err := data.Decode(&req)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", signupTokenMethodReplace, err)
}
state := &signupTokenActorState{
Tokens: make(map[string]storedSignupToken, len(req.Tokens)),
}
for _, t := range req.Tokens {
state.Tokens[t.Token] = t
}
err = a.saveState(parentCtx, state)
if err != nil {
return err
}
return a.scheduleCleanup(parentCtx, state)
}
// scheduleCleanup sets the cleanup alarm to fire when the earliest-expiring token expires, or deletes it when there are no tokens left.
func (a *signupTokenActor) scheduleCleanup(parentCtx context.Context, state *signupTokenActorState) (err error) {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
earliest, found := state.earliestExpiration()
if !found {
// No tokens: remove the alarm (if any)
err = a.client.DeleteAlarm(ctx, cleanupAlarmName)
if err != nil && !errors.Is(err, actor.ErrAlarmNotFound) {
return fmt.Errorf("error deleting cleanup alarm: %w", err)
}
return nil
}
err = a.client.SetAlarm(ctx, cleanupAlarmName, actor.AlarmProperties{DueTime: earliest})
if err != nil {
return fmt.Errorf("error setting cleanup alarm: %w", err)
}
return nil
}
// mustGetState retrieves the actor state, initializing an empty one if it doesn't exist yet.
func (a *signupTokenActor) mustGetState(parentCtx context.Context) (*signupTokenActorState, error) {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
if state == nil {
state = &signupTokenActorState{
Tokens: map[string]storedSignupToken{},
}
} else if state.Tokens == nil {
state.Tokens = map[string]storedSignupToken{}
}
return state, nil
}
func (a *signupTokenActor) saveState(parentCtx context.Context, state *signupTokenActorState) error {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err := a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}
// collectTokens returns all tokens in the state as a slice.
func collectTokens(state *signupTokenActorState) []storedSignupToken {
if state == nil {
return nil
}
tokens := make([]storedSignupToken, len(state.Tokens))
var i int
for _, t := range state.Tokens {
tokens[i] = t
i++
}
return tokens
}

View File

@@ -0,0 +1,194 @@
package usersignup
import (
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/require"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// newSignupTokenActorService starts a test actor host with the signup token singleton actor registered and returns its service.
// seed, if not nil, is used as the bootstrap data to migrate tokens into the actor's state.
func newSignupTokenActorService(t *testing.T, seed []storedSignupToken) *actor.Service {
t.Helper()
var svc *actor.Service
testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
err := h.RegisterSingletonActor(
SignupTokenActorType, NewSignupTokenActor,
local.WithBootstrapData(&signupTokenBootstrap{Tokens: seed}),
local.WithIdleTimeout(-1),
)
require.NoError(t, err)
svc = h.Service()
})
require.NotNil(t, svc)
return svc
}
func createSignupTokenForTest(t *testing.T, svc *actor.Service, token storedSignupToken) {
t.Helper()
_, err := svc.Invoke(t.Context(), SignupTokenActorType, actor.SingletonActorID, signupTokenMethodCreate, token)
require.NoError(t, err)
}
func consumeSignupTokenForTest(t *testing.T, svc *actor.Service, token string) signupTokenConsumeResponse {
t.Helper()
res, err := svc.Invoke(t.Context(), SignupTokenActorType, actor.SingletonActorID, signupTokenMethodConsume, signupTokenConsumeRequest{Token: token})
require.NoError(t, err)
var out signupTokenConsumeResponse
require.NoError(t, res.Decode(&out))
return out
}
func listSignupTokensForTest(t *testing.T, svc *actor.Service) []storedSignupToken {
t.Helper()
res, err := svc.Peek(t.Context(), SignupTokenActorType, actor.SingletonActorID, signupTokenMethodList, nil)
require.NoError(t, err)
var out signupTokenListResponse
require.NoError(t, res.Decode(&out))
return out.Tokens
}
func TestSignupTokenActorConsume(t *testing.T) {
svc := newSignupTokenActorService(t, nil)
createSignupTokenForTest(t, svc, storedSignupToken{
ID: "id-1",
Token: "token-1",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 1,
UserGroupIDs: []string{"group-a", "group-b"},
CreatedAt: time.Now(),
})
// First consume succeeds and returns the token's user groups
res := consumeSignupTokenForTest(t, svc, "token-1")
require.Equal(t, signupTokenConsumeOK, res.Status)
require.Equal(t, []string{"group-a", "group-b"}, res.UserGroupIDs)
// Second consume fails: the usage limit (1) has been reached
res = consumeSignupTokenForTest(t, svc, "token-1")
require.Equal(t, signupTokenConsumeLimitReached, res.Status)
}
func TestSignupTokenActorConsumeNotFound(t *testing.T) {
svc := newSignupTokenActorService(t, nil)
res := consumeSignupTokenForTest(t, svc, "does-not-exist")
require.Equal(t, signupTokenConsumeNotFound, res.Status)
}
func TestSignupTokenActorConsumeExpired(t *testing.T) {
svc := newSignupTokenActorService(t, nil)
createSignupTokenForTest(t, svc, storedSignupToken{
ID: "id-expired",
Token: "token-expired",
ExpiresAt: time.Now().Add(-time.Minute),
UsageLimit: 1,
CreatedAt: time.Now().Add(-time.Hour),
})
res := consumeSignupTokenForTest(t, svc, "token-expired")
require.Equal(t, signupTokenConsumeExpired, res.Status)
}
func TestSignupTokenActorRelease(t *testing.T) {
svc := newSignupTokenActorService(t, nil)
createSignupTokenForTest(t, svc, storedSignupToken{
ID: "id-2",
Token: "token-2",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 2,
CreatedAt: time.Now(),
})
// Consume both uses
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-2").Status)
// Release one use (compensation)
_, err := svc.Invoke(t.Context(), SignupTokenActorType, actor.SingletonActorID, signupTokenMethodRelease, signupTokenReleaseRequest{Token: "token-2"})
require.NoError(t, err)
// Consuming succeeds again now that a use was released
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
}
func TestSignupTokenActorDelete(t *testing.T) {
svc := newSignupTokenActorService(t, nil)
createSignupTokenForTest(t, svc, storedSignupToken{
ID: "id-3",
Token: "token-3",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 1,
CreatedAt: time.Now(),
})
require.Len(t, listSignupTokensForTest(t, svc), 1)
_, err := svc.Invoke(t.Context(), SignupTokenActorType, actor.SingletonActorID, signupTokenMethodDelete, signupTokenDeleteRequest{ID: "id-3"})
require.NoError(t, err)
require.Empty(t, listSignupTokensForTest(t, svc))
// The token can no longer be consumed
require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-3").Status)
}
func TestSignupTokenActorBootstrapMigration(t *testing.T) {
now := time.Now()
seed := []storedSignupToken{
{ID: "valid", Token: "valid-token", ExpiresAt: now.Add(time.Hour), UsageLimit: 1, CreatedAt: now},
{ID: "expired", Token: "expired-token", ExpiresAt: now.Add(-time.Hour), UsageLimit: 1, CreatedAt: now},
}
svc := newSignupTokenActorService(t, seed)
// The singleton actor bootstraps asynchronously once the host is ready. Wait until the migrated (non-expired) token is available.
require.Eventually(t, func() bool {
tokens := listSignupTokensForTest(t, svc)
return len(tokens) == 1 && tokens[0].Token == "valid-token"
}, 10*time.Second, 20*time.Millisecond, "signup token actor was not bootstrapped in time")
// The migrated token can be consumed
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "valid-token").Status)
}
func TestSignupTokenStateRemoveExpired(t *testing.T) {
now := time.Now()
state := &signupTokenActorState{Tokens: map[string]storedSignupToken{
"a": {Token: "a", ExpiresAt: now.Add(time.Hour)},
"b": {Token: "b", ExpiresAt: now.Add(-time.Minute)},
"c": {Token: "c", ExpiresAt: now.Add(-time.Hour)},
}}
removed := state.removeExpired(now)
require.Equal(t, 2, removed)
require.Len(t, state.Tokens, 1)
_, ok := state.Tokens["a"]
require.True(t, ok)
}
func TestSignupTokenStateEarliestExpiration(t *testing.T) {
now := time.Now()
empty := &signupTokenActorState{Tokens: map[string]storedSignupToken{}}
_, found := empty.earliestExpiration()
require.False(t, found)
state := &signupTokenActorState{Tokens: map[string]storedSignupToken{
"a": {Token: "a", ExpiresAt: now.Add(2 * time.Hour)},
"b": {Token: "b", ExpiresAt: now.Add(time.Hour)},
"c": {Token: "c", ExpiresAt: now.Add(3 * time.Hour)},
}}
earliest, found := state.earliestExpiration()
require.True(t, found)
require.Equal(t, now.Add(time.Hour), earliest)
}

View File

@@ -0,0 +1,48 @@
//go:build e2etest
package usersignup
import (
"context"
"fmt"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
)
// SignupTokenSeed describes a signup token to seed into the actor state, used by E2E test setup.
type SignupTokenSeed struct {
ID string
Token string
ExpiresAt time.Time
UsageLimit int
UsageCount int
UserGroupIDs []string
CreatedAt time.Time
}
// SeedSignupTokens replaces the signup token singleton actor's state with the given tokens.
// It's intended for E2E test setup, where fixture tokens need to be created with exact values.
func SeedSignupTokens(ctx context.Context, actors *local.Host, seeds []SignupTokenSeed) error {
tokens := make([]storedSignupToken, len(seeds))
for i, s := range seeds {
tokens[i] = storedSignupToken{
ID: s.ID,
Token: s.Token,
ExpiresAt: s.ExpiresAt,
UsageLimit: s.UsageLimit,
UsageCount: s.UsageCount,
UserGroupIDs: s.UserGroupIDs,
CreatedAt: s.CreatedAt,
}
}
_, err := actors.Service().Invoke(ctx, SignupTokenActorType, actor.SingletonActorID, signupTokenMethodReplace, signupTokenReplaceRequest{
Tokens: tokens,
})
if err != nil {
return fmt.Errorf("failed to seed signup tokens into actor: %w", err)
}
return nil
}

View File

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

View File

@@ -3,10 +3,17 @@ package utils
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
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
// 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)
}
}

View File

@@ -1,91 +0,0 @@
package humautils
import (
"encoding/json"
"io"
"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/cookie"
)
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/openai"
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)
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")
}

View File

@@ -1,234 +0,0 @@
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/openai.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.StatusNotFound, response.Code)
}
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

@@ -1,101 +0,0 @@
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

@@ -1,98 +0,0 @@
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

@@ -1,21 +0,0 @@
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

@@ -1,29 +0,0 @@
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

@@ -1,19 +0,0 @@
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,20 +5,13 @@ import (
"errors"
"fmt"
"time"
"github.com/danielgtaylor/huma/v2"
)
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
type JSONDuration struct { //nolint:recvcheck
type JSONDuration struct {
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,11 +1,10 @@
package utils
import (
"net/url"
"reflect"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -18,11 +17,15 @@ type PaginationResponse struct {
}
type ListRequestOptions struct {
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
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
}
type FieldMeta struct {
@@ -31,19 +34,22 @@ type FieldMeta struct {
IsFilterable bool
}
func (options *ListRequestOptions) Resolve(ctx huma.Context) []error {
requestURL := ctx.URL()
options.Filters = parseNestedFilters(requestURL.Query())
return nil
func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
return listRequestOptions
}
listRequestOptions.Filters = parseNestedFilters(ctx)
return listRequestOptions
}
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) {
meta := extractModelMetadata(result)
query = applyFilters(params.Filters, query, meta)
query = applySorting(params.SortColumn, params.SortDirection, query, meta)
query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
return Paginate(params.Page, params.Limit, query, result)
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) {
@@ -99,8 +105,9 @@ func IsValidSortDirection(direction string) bool {
}
// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
func parseNestedFilters(query url.Values) map[string][]any {
func parseNestedFilters(ctx *gin.Context) 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

@@ -1,47 +1,17 @@
package webauthn
import (
"bytes"
"context"
"encoding/json"
"fmt"
"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 AppConfigResolver
@@ -51,158 +21,184 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) beginRegistration(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialCreationOptions], error) {
dbConfig, err := h.appConfig.GetConfig(ctx)
func (h *handler) beginRegistration(c *gin.Context) {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
options, err := h.service.BeginRegistration(ctx, dbConfig, httpapi.UserID(ctx))
userID := c.GetString("userID")
options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &bodyOutput[protocol.PublicKeyCredentialCreationOptions]{
SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)},
Body: options.Response,
}, nil
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (h *handler) verifyRegistration(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.WebauthnCredentialDto], error) {
sessionID, err := sessionID(ctx)
func (h *handler) verifyRegistration(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
return nil, err
_ = c.Error(&common.MissingSessionIdError{})
return
}
request := requestWithBody(ctx, input.Body)
credential, err := h.service.VerifyRegistration(ctx, sessionID, httpapi.UserID(ctx), request, httpapi.ClientIP(ctx))
userID := c.GetString("userID")
credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &output); err != nil {
return nil, err
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil
c.JSON(http.StatusOK, credentialDto)
}
func (h *handler) beginLogin(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[protocol.PublicKeyCredentialRequestOptions], error) {
options, err := h.service.BeginLogin(ctx)
func (h *handler) beginLogin(c *gin.Context) {
options, err := h.service.BeginLogin(c.Request.Context())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
return &bodyOutput[protocol.PublicKeyCredentialRequestOptions]{
SetCookie: []http.Cookie{*cookie.NewSessionIDCookie(int(options.Timeout.Seconds()), options.SessionID)},
Body: options.Response,
}, nil
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (h *handler) verifyLogin(ctx context.Context, input *credentialBodyInput) (*bodyOutput[dto.UserDto], error) {
dbConfig, err := h.appConfig.GetConfig(ctx)
func (h *handler) verifyLogin(c *gin.Context) {
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
sessionID, err := sessionID(ctx)
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
return nil, err
_ = c.Error(&common.MissingSessionIdError{})
return
}
assertion, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(input.Body))
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
user, token, err := h.service.VerifyLogin(ctx, dbConfig, sessionID, assertion, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
return &bodyOutput[dto.UserDto]{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (h *handler) listCredentials(ctx context.Context, _ *httpapi.EmptyInput) (*bodyOutput[[]dto.WebauthnCredentialDto], error) {
credentials, err := h.service.ListCredentials(ctx, httpapi.UserID(ctx))
func (h *handler) listCredentials(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := h.service.ListCredentials(c.Request.Context(), userID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &output); err != nil {
return nil, err
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
}
return &bodyOutput[[]dto.WebauthnCredentialDto]{Body: output}, nil
c.JSON(http.StatusOK, credentialDtos)
}
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
}
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) updateCredential(ctx context.Context, input *credentialUpdateInput) (*bodyOutput[dto.WebauthnCredentialDto], error) {
credential, err := h.service.UpdateCredential(ctx, httpapi.UserID(ctx), input.ID, input.Body.Name)
err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
if err != nil {
return nil, err
_ = c.Error(err)
return
}
var output dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &output); err != nil {
return nil, err
}
return &bodyOutput[dto.WebauthnCredentialDto]{Body: output}, nil
c.Status(http.StatusNoContent)
}
func (h *handler) logout(_ context.Context, _ *httpapi.EmptyInput) (*emptyOutput, error) {
return &emptyOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(-1, "")}}, 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) reauthenticate(ctx context.Context, input *optionalCredentialBodyInput) (*emptyOutput, error) {
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
}
var token string
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)
// 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
}
} else {
token, err = h.reauthenticateWithAccessToken(ctx)
// 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
}
}
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
cookie.AddReauthenticationTokenCookie(c, token)
c.Status(http.StatusNoContent)
}

View File

@@ -1,110 +0,0 @@
package webauthn
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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, time.Hour)
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,16 +2,14 @@ package webauthn
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type TokenService interface {
@@ -57,81 +55,20 @@ func New(deps Dependencies) (*Module, error) {
}
// RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints
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)
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)
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.GET("/webauthn/login/start", m.handler.beginLogin)
apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin)
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/logout", userAuth, m.handler.logout)
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.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate)
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)
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)
}
// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface

View File

@@ -0,0 +1,56 @@
-- Recreate the one_time_access_tokens table with the schema it had before it was dropped.
CREATE TABLE one_time_access_tokens
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
device_token VARCHAR(16)
);
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
-- Recreate the signup token tables with the schema they had before they were frozen.
CREATE TABLE signup_tokens (
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
CREATE TABLE signup_tokens_user_groups
(
signup_token_id UUID NOT NULL,
user_group_id UUID NOT NULL,
PRIMARY KEY (signup_token_id, user_group_id),
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
);
-- Restore the signup tokens from the frozen JSON document stored in the "kv" table.
-- json_array_elements expands the JSON array into one row per token object.
INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count)
SELECT
(e ->> 'id')::uuid,
to_timestamp((e ->> 'createdAt')::bigint),
e ->> 'token',
to_timestamp((e ->> 'expiresAt')::bigint),
(e ->> 'usageLimit')::int,
(e ->> 'usageCount')::int
FROM kv, json_array_elements(kv."value"::json) AS e
WHERE kv."key" = 'signup_tokens_migrated';
-- Restore the token/user-group associations, expanding each token's nested userGroupIds array.
INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id)
SELECT
(e ->> 'id')::uuid,
g.value::uuid
FROM kv, json_array_elements(kv."value"::json) AS e, json_array_elements_text(e -> 'userGroupIds') AS g
WHERE kv."key" = 'signup_tokens_migrated';
-- Remove the frozen signup tokens from the "kv" table.
DELETE FROM kv WHERE "key" = 'signup_tokens_migrated';

View File

@@ -0,0 +1,28 @@
-- One-time access tokens are now stored in the actor state store, so the table is no longer needed.
DROP TABLE IF EXISTS one_time_access_tokens;
-- Freeze the signup tokens.
-- Encode every signup token (with its user group IDs) as a single JSON array and store it in the "kv" table under the "signup_tokens_migrated" key, so the singleton signup token actor can seed its state from it on first startup.
-- The "HAVING count(*) > 0" clause ensures nothing is written to the "kv" table when there are no signup tokens.
-- Timestamps are stored as Unix seconds so the frozen format is identical across databases.
INSERT INTO kv ("key", "value")
SELECT 'signup_tokens_migrated', json_agg(
json_build_object(
'id', st.id,
'token', st.token,
'expiresAt', extract(epoch FROM st.expires_at)::bigint,
'usageLimit', st.usage_limit,
'usageCount', st.usage_count,
'createdAt', extract(epoch FROM st.created_at)::bigint,
'userGroupIds', COALESCE(
(SELECT json_agg(stug.user_group_id) FROM signup_tokens_user_groups stug WHERE stug.signup_token_id = st.id),
'[]'::json
)
)
)::text
FROM signup_tokens st
HAVING count(*) > 0;
-- Drop the now-frozen signup token tables.
DROP TABLE signup_tokens_user_groups;
DROP TABLE signup_tokens;

View File

@@ -0,0 +1,62 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Recreate the one_time_access_tokens table with the schema it had before it was dropped.
CREATE TABLE one_time_access_tokens
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
device_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
-- Recreate the signup token tables with the schema they had before they were frozen.
CREATE TABLE signup_tokens (
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
CREATE TABLE signup_tokens_user_groups
(
signup_token_id TEXT NOT NULL,
user_group_id TEXT NOT NULL,
PRIMARY KEY (signup_token_id, user_group_id),
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
);
-- Restore the signup tokens from the frozen JSON document stored in the "kv" table.
-- json_each expands the JSON array into one row per token object.
INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count)
SELECT
json_extract(e.value, '$.id'),
json_extract(e.value, '$.createdAt'),
json_extract(e.value, '$.token'),
json_extract(e.value, '$.expiresAt'),
json_extract(e.value, '$.usageLimit'),
json_extract(e.value, '$.usageCount')
FROM kv, json_each(kv."value") AS e
WHERE kv."key" = 'signup_tokens_migrated';
-- Restore the token/user-group associations, expanding each token's nested userGroupIds array.
INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id)
SELECT
json_extract(e.value, '$.id'),
g.value
FROM kv, json_each(kv."value") AS e, json_each(json_extract(e.value, '$.userGroupIds')) AS g
WHERE kv."key" = 'signup_tokens_migrated';
-- Remove the frozen signup tokens from the "kv" table.
DELETE FROM kv WHERE "key" = 'signup_tokens_migrated';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,35 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- One-time access tokens are now stored in the actor state store, so the table is no longer needed.
DROP TABLE IF EXISTS one_time_access_tokens;
-- Freeze the signup tokens.
-- Encode every signup token (with its user group IDs) as a single JSON array and store it in the "kv" table under the "signup_tokens_migrated" key, so the singleton signup token actor can seed its state from it on first startup.
-- The "HAVING count(*) > 0" clause ensures nothing is written to the "kv" table when there are no signup tokens.
-- Timestamps are stored as Unix seconds, matching how DateTime values are persisted on SQLite.
INSERT INTO kv ("key", "value")
SELECT 'signup_tokens_migrated', json_group_array(
json_object(
'id', st.id,
'token', st.token,
'expiresAt', st.expires_at,
'usageLimit', st.usage_limit,
'usageCount', st.usage_count,
'createdAt', st.created_at,
'userGroupIds', json((
SELECT COALESCE(json_group_array(stug.user_group_id), json_array())
FROM signup_tokens_user_groups stug
WHERE stug.signup_token_id = st.id
))
)
)
FROM signup_tokens st
HAVING count(*) > 0;
-- Drop the now-frozen signup token tables.
DROP TABLE signup_tokens_user_groups;
DROP TABLE signup_tokens;
COMMIT;
PRAGMA foreign_keys=ON;

5429
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -238,72 +238,6 @@
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
}
],
"one_time_access_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
"token": "HPe6k6uiDRRVuAQV",
"device_token": null,
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T12:39:01Z",
"id": "d3afae24-fe2d-4a98-abec-cf0b8525096a",
"token": "YCGDtftvsvYWiXd0",
"device_token": null,
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "defd5164-9d9b-4228-bbce-708e33f49360",
"token": "one-time-token",
"device_token": null,
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"signup_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"token": "VALID1234567890A",
"usage_count": 0,
"usage_limit": 1
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-12-02T12:39:02Z",
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
"token": "PARTIAL567890ABC",
"usage_count": 2,
"usage_limit": 5
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-24T12:39:02Z",
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
"token": "EXPIRED34567890B",
"usage_count": 1,
"usage_limit": 3
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
"token": "FULLYUSED567890C",
"usage_count": 1,
"usage_limit": 1
}
],
"signup_tokens_user_groups": [
{
"signup_token_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
}
],
"user_authorized_oidc_clients": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",