diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index 78397b70..6bb62023 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -46,6 +46,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
+ apiKeyService := service.NewApiKeyService(db)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
@@ -53,24 +54,24 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
- r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware for specific routes
- jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
+ authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
apiGroup := r.Group("/api")
- controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
- controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
- controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
- controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
- controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
- controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
- controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
+ controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
+ controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
+ controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
+ controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
+ controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
+ controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
+ controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
+ controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go
index ce186899..0f18d033 100644
--- a/backend/internal/common/errors.go
+++ b/backend/internal/common/errors.go
@@ -231,3 +231,27 @@ func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
+
+type InvalidAPIKeyError struct{}
+
+func (e *InvalidAPIKeyError) Error() string {
+ return "Invalid Api Key"
+}
+
+type NoAPIKeyProvidedError struct{}
+
+func (e *NoAPIKeyProvidedError) Error() string {
+ return "No API Key Provided"
+}
+
+type APIKeyNotFoundError struct{}
+
+func (e *APIKeyNotFoundError) Error() string {
+ return "API Key Not Found"
+}
+
+type APIKeyExpirationDateError struct{}
+
+func (e *APIKeyExpirationDateError) Error() string {
+ return "API Key expiration time must be in the future"
+}
diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go
new file mode 100644
index 00000000..e12f74ab
--- /dev/null
+++ b/backend/internal/controller/api_key_controller.go
@@ -0,0 +1,125 @@
+package controller
+
+import (
+ "net/http"
+
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+
+ "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"
+)
+
+// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
+
+// ApiKeyController manages API keys for authenticated users
+type ApiKeyController struct {
+ apiKeyService *service.ApiKeyService
+}
+
+// NewApiKeyController creates a new controller for API key management
+// @Summary API key management controller
+// @Description Initializes API endpoints for managing API keys
+// @Tags API Keys
+func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
+ uc := &ApiKeyController{apiKeyService: apiKeyService}
+
+ apiKeyGroup := group.Group("/api-keys")
+ apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
+ {
+ apiKeyGroup.GET("", uc.listApiKeysHandler)
+ apiKeyGroup.POST("", uc.createApiKeyHandler)
+ apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
+ }
+}
+
+// listApiKeysHandler godoc
+// @Summary List API keys
+// @Description Get a paginated list of API keys belonging to the current user
+// @Tags API Keys
+// @Param page query int false "Page number, starting from 1" default(1)
+// @Param limit query int false "Number of items per page" default(10)
+// @Param sort_column query string false "Column to sort by" default("created_at")
+// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
+// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
+// @Router /api-keys [get]
+func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
+ userID := ctx.GetString("userID")
+
+ var sortedPaginationRequest utils.SortedPaginationRequest
+ if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
+ if err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ var apiKeysDto []dto.ApiKeyDto
+ if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
+ Data: apiKeysDto,
+ Pagination: pagination,
+ })
+}
+
+// createApiKeyHandler godoc
+// @Summary Create API key
+// @Description Create a new API key for the current user
+// @Tags API Keys
+// @Param api_key body dto.ApiKeyCreateDto true "API key information"
+// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
+// @Router /api-keys [post]
+func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
+ userID := ctx.GetString("userID")
+
+ var input dto.ApiKeyCreateDto
+ if err := ctx.ShouldBindJSON(&input); err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
+ if err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ var apiKeyDto dto.ApiKeyDto
+ if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
+ ApiKey: apiKeyDto,
+ Token: token,
+ })
+}
+
+// revokeApiKeyHandler 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-keys/{id} [delete]
+func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
+ userID := ctx.GetString("userID")
+ apiKeyID := ctx.Param("id")
+
+ if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go
index a9580f04..40c84d85 100644
--- a/backend/internal/controller/app_config_controller.go
+++ b/backend/internal/controller/app_config_controller.go
@@ -12,9 +12,13 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
+// 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(
group *gin.RouterGroup,
- jwtAuthMiddleware *middleware.JwtAuthMiddleware,
+ authMiddleware *middleware.AuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
@@ -26,18 +30,18 @@ func NewAppConfigController(
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
- group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
- group.PUT("/application-configuration", jwtAuthMiddleware.Add(true), acc.updateAppConfigHandler)
+ group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
+ group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
- group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
- group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
- group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
+ group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
+ group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
+ group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
- group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
- group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
+ group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
+ group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
}
type AppConfigController struct {
@@ -46,6 +50,15 @@ type AppConfigController struct {
ldapService *service.LdapService
}
+// listAppConfigHandler godoc
+// @Summary List public application configurations
+// @Description Get all public application configurations
+// @Tags Application Configuration
+// @Accept json
+// @Produce json
+// @Success 200 {array} dto.PublicAppConfigVariableDto
+// @Failure 500 {object} object "{"error": "error message"}"
+// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
@@ -62,6 +75,15 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
c.JSON(200, configVariablesDto)
}
+// 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
+// @Security BearerAuth
+// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
@@ -78,6 +100,16 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
c.JSON(200, configVariablesDto)
}
+// 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
+// @Security BearerAuth
+// @Router /application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -100,6 +132,16 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
c.JSON(http.StatusOK, configVariablesDto)
}
+// getLogoHandler godoc
+// @Summary Get logo image
+// @Description Get the logo image for the application
+// @Tags Application Configuration
+// @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 /application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
@@ -117,15 +159,42 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
acc.getImage(c, imageName, imageType)
}
+// getFaviconHandler godoc
+// @Summary Get favicon
+// @Description Get the favicon for the application
+// @Tags Application Configuration
+// @Produce image/x-icon
+// @Success 200 {file} binary "Favicon image"
+// @Failure 404 {object} object "{"error": "File not found"}"
+// @Router /application-configuration/favicon [get]
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
+// getBackgroundImageHandler godoc
+// @Summary Get background image
+// @Description Get the background image for the application
+// @Tags Application Configuration
+// @Produce image/png
+// @Produce image/jpeg
+// @Success 200 {file} binary "Background image"
+// @Failure 404 {object} object "{"error": "File not found"}"
+// @Router /application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
+// updateLogoHandler godoc
+// @Summary Update logo
+// @Description Update the application logo
+// @Tags Application Configuration
+// @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"
+// @Security BearerAuth
+// @Router /application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
@@ -143,6 +212,15 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
acc.updateImage(c, imageName, imageType)
}
+// updateFaviconHandler godoc
+// @Summary Update favicon
+// @Description Update the application favicon
+// @Tags Application Configuration
+// @Accept multipart/form-data
+// @Param file formData file true "Favicon file (.ico)"
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /application-configuration/favicon [put]
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -158,11 +236,21 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
acc.updateImage(c, "favicon", "ico")
}
+// updateBackgroundImageHandler godoc
+// @Summary Update background image
+// @Description Update the application background image
+// @Tags Application Configuration
+// @Accept multipart/form-data
+// @Param file formData file true "Background image file"
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
+// getImage is a helper function to serve image files
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
mimeType := utils.GetImageMimeType(imageType)
@@ -171,6 +259,7 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
c.File(imagePath)
}
+// updateImage is a helper function to update image files
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
@@ -187,6 +276,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent)
}
+// syncLdapHandler godoc
+// @Summary Synchronize LDAP
+// @Description Manually trigger LDAP synchronization
+// @Tags Application Configuration
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
@@ -196,6 +292,14 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+
+// testEmailHandler godoc
+// @Summary Send test email
+// @Description Send a test email to verify email configuration
+// @Tags Application Configuration
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go
index 744a0602..2d64f928 100644
--- a/backend/internal/controller/audit_log_controller.go
+++ b/backend/internal/controller/audit_log_controller.go
@@ -11,18 +11,32 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
-func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
+// 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,
}
- group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
+ group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
+// listAuditLogsForUserHandler godoc
+// @Summary List audit logs
+// @Description Get a paginated list of audit logs for the current user
+// @Tags Audit Logs
+// @Param page query int false "Page number, starting from 1" default(1)
+// @Param limit query int false "Number of items per page" default(10)
+// @Param sort_column query string false "Column to sort by" default("created_at")
+// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
+// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
+// @Router /audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
@@ -53,8 +67,8 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
logsDtos[i] = logsDto
}
- c.JSON(http.StatusOK, gin.H{
- "data": logsDtos,
- "pagination": pagination,
+ c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
+ Data: logsDtos,
+ Pagination: pagination,
})
}
diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go
index 5a875521..bf0d9cba 100644
--- a/backend/internal/controller/custom_claim_controller.go
+++ b/backend/internal/controller/custom_claim_controller.go
@@ -9,17 +9,37 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
-func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
+// 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}
- group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
- group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
- group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
+
+ 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
}
+// 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"
+// @Failure 401 {object} object "Unauthorized"
+// @Failure 403 {object} object "Forbidden"
+// @Failure 500 {object} object "Internal server error"
+// @Security BearerAuth
+// @Router /custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
if err != nil {
@@ -30,6 +50,16 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
c.JSON(http.StatusOK, claims)
}
+// 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 /custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
@@ -54,6 +84,17 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
c.JSON(http.StatusOK, customClaimsDto)
}
+// 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"
+// @Security BearerAuth
+// @Router /custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
@@ -62,8 +103,8 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C
return
}
- userId := c.Param("userGroupId")
- claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
+ userGroupId := c.Param("userGroupId")
+ claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input)
if err != nil {
c.Error(err)
return
diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go
index e6765d72..055cca15 100644
--- a/backend/internal/controller/oidc_controller.go
+++ b/backend/internal/controller/oidc_controller.go
@@ -1,13 +1,14 @@
package controller
import (
- "github.com/pocket-id/pocket-id/backend/internal/common"
- "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"log"
"net/http"
"net/url"
"strings"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
+
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
@@ -15,30 +16,35 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
-func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
+// 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, jwtService *service.JwtService) {
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
- group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
- group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
+ group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
+ group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
- group.POST("/oidc/end-session", oc.EndSessionHandler)
- group.GET("/oidc/end-session", oc.EndSessionHandler)
+ group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
+ group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
- group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
- group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
- group.GET("/oidc/clients/:id", oc.getClientHandler)
- group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
- group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
+ 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)
- group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
- group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
+ group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
+ group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
- group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
+ group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
}
type OidcController struct {
@@ -46,6 +52,16 @@ type OidcController struct {
jwtService *service.JwtService
}
+// authorizeHandler godoc
+// @Summary Authorize OIDC client
+// @Description Start the OIDC authorization process for a client
+// @Tags OIDC
+// @Accept json
+// @Produce json
+// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
+// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
+// @Security BearerAuth
+// @Router /oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -67,6 +83,16 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
+// authorizationConfirmationRequiredHandler godoc
+// @Summary Check if authorization confirmation is required
+// @Description Check if the user needs to confirm authorization for the client
+// @Tags OIDC
+// @Accept json
+// @Produce json
+// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
+// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
+// @Security BearerAuth
+// @Router /oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -83,6 +109,19 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
}
+// createTokensHandler godoc
+// @Summary Create OIDC tokens
+// @Description Exchange authorization code for ID and access tokens
+// @Tags OIDC
+// @Accept application/x-www-form-urlencoded
+// @Produce json
+// @Param client_id formData string false "Client ID (if not using Basic Auth)"
+// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
+// @Param code formData string true "Authorization code"
+// @Param grant_type formData string true "Grant type (must be 'authorization_code')"
+// @Param code_verifier formData string false "PKCE code verifier"
+// @Success 200 {object} object "{ \"id_token\": \"string\", \"access_token\": \"string\", \"token_type\": \"Bearer\" }"
+// @Router /oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
@@ -111,6 +150,15 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
}
+// userInfoHandler godoc
+// @Summary Get user information
+// @Description Get user information based on the access token
+// @Tags OIDC
+// @Accept json
+// @Produce json
+// @Success 200 {object} object "User claims based on requested scopes"
+// @Security OAuth2AccessToken
+// @Router /oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) {
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
if len(authHeaderSplit) != 2 {
@@ -136,6 +184,30 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, claims)
}
+// userInfoHandler godoc (POST method)
+// @Summary Get user information (POST method)
+// @Description Get user information based on the access token using POST
+// @Tags OIDC
+// @Accept json
+// @Produce json
+// @Success 200 {object} object "User claims based on requested scopes"
+// @Security OAuth2AccessToken
+// @Router /oidc/userinfo [post]
+func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
+ // Implementation is the same as GET
+}
+
+// EndSessionHandler godoc
+// @Summary End OIDC session
+// @Description End user session and handle OIDC logout
+// @Tags OIDC
+// @Accept application/x-www-form-urlencoded
+// @Produce html
+// @Param id_token_hint query string false "ID token"
+// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
+// @Param state query string false "State parameter to include in the redirect"
+// @Success 302 "Redirect to post-logout URL or application logout page"
+// @Router /oidc/end-session [get]
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
@@ -174,6 +246,56 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
+// EndSessionHandler godoc (POST method)
+// @Summary End OIDC session (POST method)
+// @Description End user session and handle OIDC logout using POST
+// @Tags OIDC
+// @Accept application/x-www-form-urlencoded
+// @Produce html
+// @Param id_token_hint formData string false "ID token"
+// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
+// @Param state formData string false "State parameter to include in the redirect"
+// @Success 302 "Redirect to post-logout URL or application logout page"
+// @Router /oidc/end-session [post]
+func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
+ // Implementation is the same as GET
+}
+
+// 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 /oidc/clients/{id}/meta [get]
+func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
+ clientId := c.Param("id")
+ client, err := oc.oidcService.GetClient(clientId)
+ if err != nil {
+ c.Error(err)
+ return
+ }
+
+ clientDto := dto.OidcClientMetaDataDto{}
+ err = dto.MapStruct(client, &clientDto)
+ if err == nil {
+ c.JSON(http.StatusOK, clientDto)
+ return
+ }
+
+ c.Error(err)
+}
+
+// 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"
+// @Security BearerAuth
+// @Router /oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
@@ -182,26 +304,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
return
}
- // Return a different DTO based on the user's role
- if c.GetBool("userIsAdmin") {
- clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
- err = dto.MapStruct(client, &clientDto)
- if err == nil {
- c.JSON(http.StatusOK, clientDto)
- return
- }
- } else {
- clientDto := dto.PublicOidcClientDto{}
- err = dto.MapStruct(client, &clientDto)
- if err == nil {
- c.JSON(http.StatusOK, clientDto)
- return
- }
+ clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
+ err = dto.MapStruct(client, &clientDto)
+ if err == nil {
+ c.JSON(http.StatusOK, clientDto)
+ return
}
c.Error(err)
}
+// 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 page query int false "Page number, starting from 1" default(1)
+// @Param limit query int false "Number of items per page" default(10)
+// @Param sort_column query string false "Column to sort by" default("name")
+// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
+// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
+// @Security BearerAuth
+// @Router /oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
@@ -222,12 +346,22 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{
- "data": clientsDto,
- "pagination": pagination,
+ c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
+ Data: clientsDto,
+ Pagination: pagination,
})
}
+// 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"
+// @Security BearerAuth
+// @Router /oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -250,6 +384,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
c.JSON(http.StatusCreated, clientDto)
}
+// 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"
+// @Security BearerAuth
+// @Router /oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
@@ -260,6 +402,17 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// 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.OidcClientCreateDto true "Client information"
+// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
+// @Security BearerAuth
+// @Router /oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -282,6 +435,15 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
c.JSON(http.StatusOK, clientDto)
}
+// 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\" }"
+// @Security BearerAuth
+// @Router /oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
@@ -292,6 +454,16 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"secret": secret})
}
+// 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"
+// @Success 200 {file} binary "Logo image"
+// @Router /oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
@@ -303,6 +475,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
c.File(imagePath)
}
+// 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, max 2MB)"
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -319,6 +501,14 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
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"
+// @Success 204 "No Content"
+// @Security BearerAuth
+// @Router /oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
@@ -329,6 +519,17 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// 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"
+// @Security BearerAuth
+// @Router /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 {
diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go
index e892a3cc..21dd957f 100644
--- a/backend/internal/controller/user_controller.go
+++ b/backend/internal/controller/user_controller.go
@@ -16,30 +16,34 @@ import (
"golang.org/x/time/rate"
)
-func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
+// NewUserController creates a new controller for user management endpoints
+// @Summary User management controller
+// @Description Initializes all user-related API endpoints
+// @Tags Users
+func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
}
- group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
- group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
- group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
- group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
- group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
- group.GET("/users/:id/groups", jwtAuthMiddleware.Add(true), uc.getUserGroupsHandler)
- group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
- group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
+ group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
+ group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
+ group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
+ group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
+ group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
+ group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
+ group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
+ group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
- group.PUT("/users/:id/user-groups", jwtAuthMiddleware.Add(true), uc.updateUserGroups)
+ group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
- group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
- group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
- group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler)
+ group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler)
+ group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
+ group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
- group.POST("/users/me/one-time-access-token", jwtAuthMiddleware.Add(false), uc.createOwnOneTimeAccessTokenHandler)
- group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createAdminOneTimeAccessTokenHandler)
+ group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
+ group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
@@ -50,6 +54,13 @@ type UserController struct {
appConfigService *service.AppConfigService
}
+// getUserGroupsHandler godoc
+// @Summary Get user groups
+// @Description Retrieve all groups a specific user belongs to
+// @Tags Users,User Groups
+// @Param id path string true "User ID"
+// @Success 200 {array} dto.UserGroupDtoWithUsers
+// @Router /users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID)
@@ -67,6 +78,17 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, groupsDto)
}
+// listUsersHandler godoc
+// @Summary List users
+// @Description Get a paginated list of users with optional search and sorting
+// @Tags Users
+// @Param search query string false "Search term to filter users"
+// @Param page query int false "Page number, starting from 1" default(1)
+// @Param limit query int false "Number of items per page" default(10)
+// @Param sort_column query string false "Column to sort by" default("created_at")
+// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
+// @Success 200 {object} dto.Paginated[dto.UserDto]
+// @Router /users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
@@ -87,12 +109,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{
- "data": usersDto,
- "pagination": pagination,
+ c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
+ Data: usersDto,
+ Pagination: pagination,
})
}
+// getUserHandler godoc
+// @Summary Get user by ID
+// @Description Retrieve detailed information about a specific user
+// @Tags Users
+// @Param id path string true "User ID"
+// @Success 200 {object} dto.UserDto
+// @Router /users/{id} [get]
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.Param("id"))
if err != nil {
@@ -109,6 +138,12 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
+// getCurrentUserHandler godoc
+// @Summary Get current user
+// @Description Retrieve information about the currently authenticated user
+// @Tags Users
+// @Success 200 {object} dto.UserDto
+// @Router /users/me [get]
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
@@ -125,6 +160,13 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
+// deleteUserHandler godoc
+// @Summary Delete user
+// @Description Delete a specific user by ID
+// @Tags Users
+// @Param id path string true "User ID"
+// @Success 204 "No Content"
+// @Router /users/{id} [delete]
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
@@ -134,6 +176,13 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// createUserHandler godoc
+// @Summary Create user
+// @Description Create a new user
+// @Tags Users
+// @Param user body dto.UserCreateDto true "User information"
+// @Success 201 {object} dto.UserDto
+// @Router /users [post]
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -156,10 +205,25 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
c.JSON(http.StatusCreated, userDto)
}
+// updateUserHandler godoc
+// @Summary Update user
+// @Description Update an existing user by ID
+// @Tags Users
+// @Param id path string true "User ID"
+// @Param user body dto.UserCreateDto true "User information"
+// @Success 200 {object} dto.UserDto
+// @Router /users/{id} [put]
func (uc *UserController) updateUserHandler(c *gin.Context) {
uc.updateUser(c, false)
}
+// updateCurrentUserHandler godoc
+// @Summary Update current user
+// @Description Update the currently authenticated user's information
+// @Tags Users
+// @Param user body dto.UserCreateDto true "User information"
+// @Success 200 {object} dto.UserDto
+// @Router /users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
@@ -168,6 +232,14 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
uc.updateUser(c, true)
}
+// getUserProfilePictureHandler godoc
+// @Summary Get user profile picture
+// @Description Retrieve a specific user's profile picture
+// @Tags Users
+// @Produce image/png
+// @Param id path string true "User ID"
+// @Success 200 {file} binary "PNG image"
+// @Router /users/{id}/profile-picture.png [get]
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
@@ -180,6 +252,13 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
+// getCurrentUserProfilePictureHandler godoc
+// @Summary Get current user's profile picture
+// @Description Retrieve the currently authenticated user's profile picture
+// @Tags Users
+// @Produce image/png
+// @Success 200 {file} binary "PNG image"
+// @Router /users/me/profile-picture.png [get]
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
@@ -192,6 +271,16 @@ func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
+// updateUserProfilePictureHandler godoc
+// @Summary Update user profile picture
+// @Description Update a specific user's profile picture
+// @Tags Users
+// @Accept multipart/form-data
+// @Produce json
+// @Param id path string true "User ID"
+// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
+// @Success 204 "No Content"
+// @Router /users/{id}/profile-picture [put]
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
@@ -214,6 +303,15 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// updateCurrentUserProfilePictureHandler godoc
+// @Summary Update current user's profile picture
+// @Description Update the currently authenticated user's profile picture
+// @Tags Users
+// @Accept multipart/form-data
+// @Produce json
+// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
+// @Success 204 "No Content"
+// @Router /users/me/profile-picture [put]
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
@@ -255,6 +353,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
c.JSON(http.StatusCreated, gin.H{"token": token})
}
+// createOwnOneTimeAccessTokenHandler godoc
+// @Summary Create one-time access token for current user
+// @Description Generate a one-time access token for the currently authenticated user
+// @Tags Users
+// @Param id path string true "User ID"
+// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
+// @Success 201 {object} object "{ \"token\": \"string\" }"
+// @Router /users/{id}/one-time-access-token [post]
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, true)
}
@@ -279,6 +385,13 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// exchangeOneTimeAccessTokenHandler godoc
+// @Summary Exchange one-time access token
+// @Description Exchange a one-time access token for a session token
+// @Tags Users
+// @Param token path string true "One-time access token"
+// @Success 200 {object} dto.UserDto
+// @Router /one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
@@ -299,6 +412,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
+// getSetupAccessTokenHandler godoc
+// @Summary Setup initial admin
+// @Description Generate setup access token for initial admin user configuration
+// @Tags Users
+// @Success 200 {object} dto.UserDto
+// @Router /one-time-access-token/setup [post]
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
@@ -319,6 +438,37 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
+// updateUserGroups godoc
+// @Summary Update user groups
+// @Description Update the groups a specific user belongs to
+// @Tags Users
+// @Param id path string true "User ID"
+// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
+// @Success 200 {object} dto.UserDto
+// @Router /users/{id}/user-groups [put]
+func (uc *UserController) updateUserGroups(c *gin.Context) {
+ var input dto.UserUpdateUserGroupDto
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.Error(err)
+ return
+ }
+
+ user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
+ if err != nil {
+ c.Error(err)
+ return
+ }
+
+ var userDto dto.UserDto
+ if err := dto.MapStruct(user, &userDto); err != nil {
+ c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, userDto)
+}
+
+// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -347,25 +497,3 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto)
}
-
-func (uc *UserController) updateUserGroups(c *gin.Context) {
- var input dto.UserUpdateUserGroupDto
- if err := c.ShouldBindJSON(&input); err != nil {
- c.Error(err)
- return
- }
-
- user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
- if err != nil {
- c.Error(err)
- return
- }
-
- var userDto dto.UserDto
- if err := dto.MapStruct(user, &userDto); err != nil {
- c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, userDto)
-}
diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go
index 1c0dcbe5..4f2e22e2 100644
--- a/backend/internal/controller/user_group_controller.go
+++ b/backend/internal/controller/user_group_controller.go
@@ -10,23 +10,42 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
-func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
+// NewUserGroupController creates a new controller for user group management
+// @Summary User group management controller
+// @Description Initializes all user group-related API endpoints
+// @Tags User Groups
+func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
ugc := UserGroupController{
UserGroupService: userGroupService,
}
- group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
- group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
- group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
- group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
- group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
- group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
+ 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)
+ }
}
type UserGroupController struct {
UserGroupService *service.UserGroupService
}
+// list godoc
+// @Summary List user groups
+// @Description Get a paginated list of user groups with optional search and sorting
+// @Tags User Groups
+// @Param search query string false "Search term to filter user groups by name"
+// @Param page query int false "Page number, starting from 1" default(1)
+// @Param limit query int false "Number of items per page" default(10)
+// @Param sort_column query string false "Column to sort by" default("name")
+// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
+// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
+// @Router /user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
@@ -41,7 +60,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
return
}
- // Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
+ // Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
@@ -57,12 +76,22 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groupsDto[i] = groupDto
}
- c.JSON(http.StatusOK, gin.H{
- "data": groupsDto,
- "pagination": pagination,
+ c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
+ Data: groupsDto,
+ Pagination: pagination,
})
}
+// 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.UserGroupDtoWithUsers
+// @Security BearerAuth
+// @Router /user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil {
@@ -79,6 +108,16 @@ func (ugc *UserGroupController) get(c *gin.Context) {
c.JSON(http.StatusOK, groupDto)
}
+// 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.UserGroupDtoWithUsers "Created user group"
+// @Security BearerAuth
+// @Router /user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -101,6 +140,17 @@ func (ugc *UserGroupController) create(c *gin.Context) {
c.JSON(http.StatusCreated, groupDto)
}
+// 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.UserGroupDtoWithUsers "Updated user group"
+// @Security BearerAuth
+// @Router /user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
@@ -123,6 +173,16 @@ func (ugc *UserGroupController) update(c *gin.Context) {
c.JSON(http.StatusOK, groupDto)
}
+// 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"
+// @Security BearerAuth
+// @Router /user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
c.Error(err)
@@ -132,6 +192,17 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// 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.UserGroupDtoWithUsers
+// @Security BearerAuth
+// @Router /user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go
index 597ad519..ce7c49ea 100644
--- a/backend/internal/controller/webauthn_controller.go
+++ b/backend/internal/controller/webauthn_controller.go
@@ -16,19 +16,19 @@ import (
"golang.org/x/time/rate"
)
-func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
+func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
- group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
- group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
+ group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
+ group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
group.GET("/webauthn/login/start", wc.beginLoginHandler)
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
- group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler)
+ group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
- group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler)
- group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
- group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
+ group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
+ group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
+ group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
}
type WebauthnController struct {
diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go
index 4c7f7ae7..1debaf2f 100644
--- a/backend/internal/controller/well_known_controller.go
+++ b/backend/internal/controller/well_known_controller.go
@@ -8,6 +8,10 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
+// 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}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
@@ -18,6 +22,13 @@ type WellKnownController struct {
jwtService *service.JwtService
}
+// 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) {
jwk, err := wkc.jwtService.GetJWK()
if err != nil {
@@ -28,6 +39,12 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
}
+// 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) {
appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{
diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go
new file mode 100644
index 00000000..989176e9
--- /dev/null
+++ b/backend/internal/dto/api_key_dto.go
@@ -0,0 +1,25 @@
+package dto
+
+import (
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+)
+
+type ApiKeyCreateDto struct {
+ Name string `json:"name" binding:"required,min=3,max=50"`
+ Description string `json:"description"`
+ ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
+}
+
+type ApiKeyDto struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ExpiresAt datatype.DateTime `json:"expiresAt"`
+ LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
+ CreatedAt datatype.DateTime `json:"createdAt"`
+}
+
+type ApiKeyResponseDto struct {
+ ApiKey ApiKeyDto `json:"apiKey"`
+ Token string `json:"token"`
+}
diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go
index 78a1fcb7..ca21aa92 100644
--- a/backend/internal/dto/oidc_dto.go
+++ b/backend/internal/dto/oidc_dto.go
@@ -1,13 +1,13 @@
package dto
-type PublicOidcClientDto struct {
+type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
- PublicOidcClientDto
+ OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
@@ -15,12 +15,8 @@ type OidcClientDto struct {
}
type OidcClientWithAllowedUserGroupsDto struct {
- PublicOidcClientDto
- CallbackURLs []string `json:"callbackURLs"`
- LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
- IsPublic bool `json:"isPublic"`
- PkceEnabled bool `json:"pkceEnabled"`
- AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
+ OidcClientDto
+ AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientCreateDto struct {
diff --git a/backend/internal/dto/pagination_dto.go b/backend/internal/dto/pagination_dto.go
new file mode 100644
index 00000000..9ae33cbf
--- /dev/null
+++ b/backend/internal/dto/pagination_dto.go
@@ -0,0 +1,10 @@
+package dto
+
+import "github.com/pocket-id/pocket-id/backend/internal/utils"
+
+type Pagination = utils.PaginationResponse
+
+type Paginated[T any] struct {
+ Data []T `json:"data"`
+ Pagination Pagination `json:"pagination"`
+}
diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go
new file mode 100644
index 00000000..79b32ee7
--- /dev/null
+++ b/backend/internal/middleware/api_key_auth.go
@@ -0,0 +1,50 @@
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/service"
+)
+
+type ApiKeyAuthMiddleware struct {
+ apiKeyService *service.ApiKeyService
+ jwtService *service.JwtService
+}
+
+func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
+ return &ApiKeyAuthMiddleware{
+ apiKeyService: apiKeyService,
+ jwtService: jwtService,
+ }
+}
+
+func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userID, isAdmin, err := m.Verify(c, adminRequired)
+ if err != nil {
+ c.Abort()
+ c.Error(err)
+ return
+ }
+
+ c.Set("userID", userID)
+ c.Set("userIsAdmin", isAdmin)
+ c.Next()
+ }
+}
+
+func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
+ apiKey := c.GetHeader("X-API-KEY")
+
+ user, err := m.apiKeyService.ValidateApiKey(apiKey)
+ if err != nil {
+ return "", false, &common.NotSignedInError{}
+ }
+
+ // Check if the user is an admin
+ if adminRequired && !user.IsAdmin {
+ return "", false, &common.MissingPermissionError{}
+ }
+
+ return user.ID, user.IsAdmin, nil
+}
diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go
new file mode 100644
index 00000000..29b8ea8f
--- /dev/null
+++ b/backend/internal/middleware/auth_middleware.go
@@ -0,0 +1,89 @@
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/pocket-id/pocket-id/backend/internal/service"
+)
+
+// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
+type AuthMiddleware struct {
+ apiKeyMiddleware *ApiKeyAuthMiddleware
+ jwtMiddleware *JwtAuthMiddleware
+ options AuthOptions
+}
+
+type AuthOptions struct {
+ AdminRequired bool
+ SuccessOptional bool
+}
+
+func NewAuthMiddleware(
+ apiKeyService *service.ApiKeyService,
+ jwtService *service.JwtService,
+) *AuthMiddleware {
+ return &AuthMiddleware{
+ apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
+ jwtMiddleware: NewJwtAuthMiddleware(jwtService),
+ options: AuthOptions{
+ AdminRequired: true,
+ SuccessOptional: false,
+ },
+ }
+}
+
+// WithAdminNotRequired allows the middleware to continue with the request even if the user is not an admin
+func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware {
+ // Create a new instance to avoid modifying the original
+ clone := &AuthMiddleware{
+ apiKeyMiddleware: m.apiKeyMiddleware,
+ jwtMiddleware: m.jwtMiddleware,
+ options: m.options,
+ }
+ clone.options.AdminRequired = false
+ return clone
+}
+
+// WithSuccessOptional allows the middleware to continue with the request even if authentication fails
+func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
+ // Create a new instance to avoid modifying the original
+ clone := &AuthMiddleware{
+ apiKeyMiddleware: m.apiKeyMiddleware,
+ jwtMiddleware: m.jwtMiddleware,
+ options: m.options,
+ }
+ clone.options.SuccessOptional = true
+ return clone
+}
+
+func (m *AuthMiddleware) Add() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // First try JWT auth
+ userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
+ if err == nil {
+ // JWT auth succeeded, continue with the request
+ c.Set("userID", userID)
+ c.Set("userIsAdmin", isAdmin)
+ c.Next()
+ return
+ }
+
+ // JWT auth failed, try API key auth
+ userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
+ if err == nil {
+ // API key auth succeeded, continue with the request
+ c.Set("userID", userID)
+ c.Set("userIsAdmin", isAdmin)
+ c.Next()
+ return
+ }
+
+ if m.options.SuccessOptional {
+ c.Next()
+ return
+ }
+
+ // Both JWT and API key auth failed
+ c.Abort()
+ c.Error(err)
+ }
+}
diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go
index 36f7e9e8..572f14c1 100644
--- a/backend/internal/middleware/jwt_auth.go
+++ b/backend/internal/middleware/jwt_auth.go
@@ -10,51 +10,50 @@ import (
)
type JwtAuthMiddleware struct {
- jwtService *service.JwtService
- ignoreUnauthenticated bool
+ jwtService *service.JwtService
}
-func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
- return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
+func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
+ return &JwtAuthMiddleware{jwtService: jwtService}
}
-func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
+func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
- // Extract the token from the cookie or the Authorization header
- token, err := c.Cookie(cookie.AccessTokenCookieName)
+
+ userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
- authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
- if len(authorizationHeaderSplitted) == 2 {
- token = authorizationHeaderSplitted[1]
- } else if m.ignoreUnauthenticated {
- c.Next()
- return
- } else {
- c.Error(&common.NotSignedInError{})
- c.Abort()
- return
- }
- }
-
- claims, err := m.jwtService.VerifyAccessToken(token)
- if err != nil && m.ignoreUnauthenticated {
- c.Next()
- return
- } else if err != nil {
- c.Error(&common.NotSignedInError{})
c.Abort()
+ c.Error(err)
return
}
- // Check if the user is an admin
- if adminOnly && !claims.IsAdmin {
- c.Error(&common.MissingPermissionError{})
- c.Abort()
- return
- }
-
- c.Set("userID", claims.Subject)
- c.Set("userIsAdmin", claims.IsAdmin)
+ c.Set("userID", userID)
+ c.Set("userIsAdmin", isAdmin)
c.Next()
}
}
+
+func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
+ // Extract the token from the cookie
+ token, err := c.Cookie(cookie.AccessTokenCookieName)
+ if err != nil {
+ // Try to extract the token from the Authorization header if it's not in the cookie
+ authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
+ if len(authorizationHeaderSplit) != 2 {
+ return "", false, &common.NotSignedInError{}
+ }
+ token = authorizationHeaderSplit[1]
+ }
+
+ claims, err := m.jwtService.VerifyAccessToken(token)
+ if err != nil {
+ return "", false, &common.NotSignedInError{}
+ }
+
+ // Check if the user is an admin
+ if adminRequired && !claims.IsAdmin {
+ return "", false, &common.MissingPermissionError{}
+ }
+
+ return claims.Subject, claims.IsAdmin, nil
+}
diff --git a/backend/internal/model/api_key.go b/backend/internal/model/api_key.go
new file mode 100644
index 00000000..456bcb05
--- /dev/null
+++ b/backend/internal/model/api_key.go
@@ -0,0 +1,18 @@
+package model
+
+import (
+ "github.com/pocket-id/pocket-id/backend/internal/model/types"
+)
+
+type ApiKey struct {
+ Base
+
+ Name string `sortable:"true"`
+ Key string
+ Description *string
+ ExpiresAt datatype.DateTime `sortable:"true"`
+ LastUsedAt *datatype.DateTime `sortable:"true"`
+
+ UserID string
+ User User
+}
diff --git a/backend/internal/model/base.go b/backend/internal/model/base.go
index e93e127e..012f0f36 100644
--- a/backend/internal/model/base.go
+++ b/backend/internal/model/base.go
@@ -4,20 +4,20 @@ import (
"time"
"github.com/google/uuid"
- model "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
// Base contains common columns for all tables.
type Base struct {
- ID string `gorm:"primaryKey;not null"`
- CreatedAt model.DateTime `sortable:"true"`
+ ID string `gorm:"primaryKey;not null"`
+ CreatedAt datatype.DateTime `sortable:"true"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
- b.CreatedAt = model.DateTime(time.Now())
+ b.CreatedAt = datatype.DateTime(time.Now())
return
}
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
new file mode 100644
index 00000000..9db2c4e0
--- /dev/null
+++ b/backend/internal/service/api_key_service.go
@@ -0,0 +1,102 @@
+package service
+
+import (
+ "errors"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "log"
+ "time"
+
+ "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"
+ "gorm.io/gorm"
+)
+
+type ApiKeyService struct {
+ db *gorm.DB
+}
+
+func NewApiKeyService(db *gorm.DB) *ApiKeyService {
+ return &ApiKeyService{db: db}
+}
+
+func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
+ query := s.db.Where("user_id = ?", userID).Model(&model.ApiKey{})
+
+ var apiKeys []model.ApiKey
+ pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
+ if err != nil {
+ return nil, utils.PaginationResponse{}, err
+ }
+
+ return apiKeys, pagination, nil
+}
+
+func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
+ // Check if expiration is in the future
+ if !input.ExpiresAt.ToTime().After(time.Now()) {
+ return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
+ }
+
+ // Generate a secure random API key
+ token, err := utils.GenerateRandomAlphanumericString(32)
+ if err != nil {
+ return model.ApiKey{}, "", err
+ }
+
+ apiKey := model.ApiKey{
+ Name: input.Name,
+ Key: utils.CreateSha256Hash(token), // Hash the token for storage
+ Description: &input.Description,
+ ExpiresAt: datatype.DateTime(input.ExpiresAt),
+ UserID: userID,
+ }
+
+ if err := s.db.Create(&apiKey).Error; err != nil {
+ return model.ApiKey{}, "", err
+ }
+
+ // Return the raw token only once - it cannot be retrieved later
+ return apiKey, token, nil
+}
+
+func (s *ApiKeyService) RevokeApiKey(userID, apiKeyID string) error {
+ var apiKey model.ApiKey
+ if err := s.db.Where("id = ? AND user_id = ?", apiKeyID, userID).First(&apiKey).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &common.APIKeyNotFoundError{}
+ }
+ return err
+ }
+
+ return s.db.Delete(&apiKey).Error
+}
+
+func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
+ if apiKey == "" {
+ return model.User{}, &common.NoAPIKeyProvidedError{}
+ }
+
+ var key model.ApiKey
+ hashedKey := utils.CreateSha256Hash(apiKey)
+
+ if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
+ hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return model.User{}, &common.InvalidAPIKeyError{}
+ }
+
+ return model.User{}, err
+ }
+
+ // Update last used time
+ now := datatype.DateTime(time.Now())
+ key.LastUsedAt = &now
+ if err := s.db.Save(&key).Error; err != nil {
+ log.Printf("Failed to update last used time: %v", err)
+ }
+
+ return key.User, nil
+}
diff --git a/backend/internal/service/test_service.go b/backend/internal/service/test_service.go
index 89d208ff..1c5d3d3c 100644
--- a/backend/internal/service/test_service.go
+++ b/backend/internal/service/test_service.go
@@ -212,6 +212,18 @@ func (s *TestService) SeedDatabase() error {
return err
}
+ apiKey := model.ApiKey{
+ Base: model.Base{
+ ID: "5f1fa856-c164-4295-961e-175a0d22d725",
+ },
+ Name: "Test API Key",
+ Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
+ UserID: users[0].ID,
+ }
+ if err := tx.Create(&apiKey).Error; err != nil {
+ return err
+ }
+
return nil
})
}
diff --git a/backend/internal/utils/hash_util.go b/backend/internal/utils/hash_util.go
new file mode 100644
index 00000000..d80c41f1
--- /dev/null
+++ b/backend/internal/utils/hash_util.go
@@ -0,0 +1,11 @@
+package utils
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+)
+
+func CreateSha256Hash(input string) string {
+ hash := sha256.Sum256([]byte(input))
+ return hex.EncodeToString(hash[:])
+}
diff --git a/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql b/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql
new file mode 100644
index 00000000..3669e0ac
--- /dev/null
+++ b/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_api_keys_key;
+DROP TABLE IF EXISTS api_keys;
\ No newline at end of file
diff --git a/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql b/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql
new file mode 100644
index 00000000..9b64589f
--- /dev/null
+++ b/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE api_keys (
+ id UUID PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ key VARCHAR(255) NOT NULL UNIQUE,
+ description TEXT,
+ expires_at TIMESTAMPTZ NOT NULL,
+ last_used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ,
+ user_id UUID REFERENCES users ON DELETE CASCADE
+);
+
+CREATE INDEX idx_api_keys_key ON api_keys(key);
\ No newline at end of file
diff --git a/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql
new file mode 100644
index 00000000..3669e0ac
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_api_keys_key;
+DROP TABLE IF EXISTS api_keys;
\ No newline at end of file
diff --git a/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql
new file mode 100644
index 00000000..3e27cb83
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE api_keys (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ key TEXT NOT NULL UNIQUE,
+ description TEXT,
+ expires_at DATETIME NOT NULL,
+ last_used_at DATETIME,
+ created_at DATETIME,
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_api_keys_key ON api_keys(key);
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 52b5a101..9ff66405 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
- "version": "0.37.0",
+ "version": "0.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
- "version": "0.37.0",
+ "version": "0.38.0",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -16,7 +16,7 @@
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6",
- "lucide-svelte": "^0.474.0",
+ "lucide-svelte": "^0.479.0",
"mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1",
@@ -25,6 +25,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
+ "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
@@ -3300,9 +3301,9 @@
"dev": true
},
"node_modules/lucide-svelte": {
- "version": "0.474.0",
- "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.474.0.tgz",
- "integrity": "sha512-yOSqjXPoEDOXCceBIfDaed6RinOvhp03ShiTXH6O+vlXE/NsyjQpktL8gm2vGDxi9d81HMuPFN1dwhVURh6mGg==",
+ "version": "0.479.0",
+ "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz",
+ "integrity": "sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
diff --git a/frontend/package.json b/frontend/package.json
index f22ec98d..3e4b320f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,7 +21,7 @@
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6",
- "lucide-svelte": "^0.474.0",
+ "lucide-svelte": "^0.479.0",
"mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1",
@@ -30,6 +30,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
+ "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
diff --git a/frontend/src/lib/components/form/date-picker.svelte b/frontend/src/lib/components/form/date-picker.svelte
new file mode 100644
index 00000000..8d57df1c
--- /dev/null
+++ b/frontend/src/lib/components/form/date-picker.svelte
@@ -0,0 +1,53 @@
+
+
+
{apiKeyResponse.apiKey.name}
+ + {#if apiKeyResponse.apiKey.description} +{apiKeyResponse.apiKey.description}
+ {/if} + +