mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-23 13:54:42 +03:00
Compare commits
8 Commits
feat/huma-
...
actors/hea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060242971c | ||
|
|
20c7325c3b | ||
|
|
6e859de2dd | ||
|
|
2341f4fc4a | ||
|
|
f6b02efe45 | ||
|
|
e10f66c07a | ||
|
|
ad06ea6e00 | ||
|
|
599f7d118d |
@@ -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,10 +20,11 @@ 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
|
||||
github.com/italypaleale/francis v0.1.0-beta.11
|
||||
github.com/italypaleale/francis v0.1.0-beta.12
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be
|
||||
github.com/italypaleale/go-sql-utils v0.2.4
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
@@ -66,10 +66,10 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.65.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.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
|
||||
@@ -137,7 +136,7 @@ require (
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -173,7 +172,7 @@ require (
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
|
||||
@@ -4,18 +4,18 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||
github.com/ClickHouse/ch-go v0.65.0 h1:vZAXfTQliuNNefqkPDewX3kgRxN6Q4vUENnnY+ynTRY=
|
||||
github.com/ClickHouse/ch-go v0.65.0/go.mod h1:tCM0XEH5oWngoi9Iu/8+tjPBo04I/FxNIffpdjtwx3k=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 h1:zVWJUmUGdtCApM/vRfQhruGXIm1M643bk68B3IYbR1I=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0/go.mod h1:rGFIgeNbJVggBp2C+0FXOdfjsMlpsKx7FUYnHHyy2KE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
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=
|
||||
@@ -259,14 +257,16 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.11 h1:FurXV2vMkRzJRFldQ6Z/bhLSJz8YXHm84uiASGeyWWU=
|
||||
github.com/italypaleale/francis v0.1.0-beta.11/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
|
||||
github.com/italypaleale/francis v0.1.0-beta.12 h1:v3D2w2uH7O0TKY5guCULMbpNH/Uo/kJNPJ9QMLGwFXU=
|
||||
github.com/italypaleale/francis v0.1.0-beta.12/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be h1:jgu+Mdsda++LqPxz8cj8vvgiFINQ8PhFB4Q1VZpyPjs=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
|
||||
@@ -421,8 +421,8 @@ github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7ol
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -523,8 +523,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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/components/postgres"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -43,22 +44,34 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
|
||||
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
}
|
||||
|
||||
// Derive the cluster host limit from the HA setting
|
||||
// With HA disabled the cluster is capped at a single replica; enabling HA lifts the cap
|
||||
maxHosts := 1
|
||||
if o.EnvConfig.HAEnabled {
|
||||
maxHosts = 0
|
||||
}
|
||||
|
||||
// Options for the host
|
||||
opts := []local.HostOption{
|
||||
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
local.WithLogger(log.With("scope", "actor-host")),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
// TODO: Tweak these values once Pocket ID fully supports horizontal scaling.
|
||||
// The relaxed intervals are appropriate for a single active host, but should be
|
||||
// tuned for lower latency and better distribution across a multi-host cluster.
|
||||
local.WithHostHealthCheckDeadline(90 * time.Second),
|
||||
local.WithAlarmsPollInterval(5 * time.Minute),
|
||||
local.WithAlarmsFetchAheadInterval(5 * time.Minute),
|
||||
local.WithMaxHosts(maxHosts),
|
||||
local.WithHostHealthCheckDeadline(ActorsHostHealthCheckDeadline(o.EnvConfig.HAEnabled)),
|
||||
}
|
||||
|
||||
// With a single active host the relaxed alarm intervals reduce database load
|
||||
// When HA is enabled they are dropped so Francis uses its tighter defaults, which distribute alarm work and fail over faster across multiple hosts
|
||||
if !o.EnvConfig.HAEnabled {
|
||||
opts = append(opts,
|
||||
local.WithAlarmsPollInterval(5*time.Minute),
|
||||
local.WithAlarmsFetchAheadInterval(5*time.Minute),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the database connection
|
||||
providerOpt, err := o.getProvider()
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -98,23 +111,50 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
|
||||
// nonHAHostHealthCheckDeadline is the relaxed host health-check deadline used when HA is disabled
|
||||
// A single active host does not need aggressive health checks, so a longer deadline reduces database load
|
||||
const nonHAHostHealthCheckDeadline = 90 * time.Second
|
||||
|
||||
// ActorsHostHealthCheckDeadline returns the health-check deadline the actor host uses for the given HA setting
|
||||
// The cluster admin used during import must pass the same value so it waits the right amount of time for hosts to drain
|
||||
func ActorsHostHealthCheckDeadline(haEnabled bool) time.Duration {
|
||||
if haEnabled {
|
||||
return components.DefaultHostHealthCheckDeadline
|
||||
}
|
||||
return nonHAHostHealthCheckDeadline
|
||||
}
|
||||
|
||||
// ActorsProviderOptions builds the Francis provider options for the given database handles
|
||||
// The actor host and the cluster admin must use the same options so they address the same cluster
|
||||
func ActorsProviderOptions(pg *pgxpool.Pool, sqliteDB *sql.DB) (components.ProviderOptions, error) {
|
||||
switch {
|
||||
case o.Postgres != nil && o.SQLite != nil:
|
||||
case pg != nil && sqliteDB != nil:
|
||||
return nil, errors.New("cannot have both Postgres and SQLite connections")
|
||||
case o.Postgres != nil:
|
||||
return local.WithPostgresProvider(postgres.PostgresProviderOptions{
|
||||
DB: o.Postgres,
|
||||
}), nil
|
||||
case o.SQLite != nil:
|
||||
return local.WithSQLiteProvider(local.SQLiteProviderOptions{
|
||||
DB: o.SQLite,
|
||||
}), nil
|
||||
case pg != nil:
|
||||
return postgres.PostgresProviderOptions{DB: pg}, nil
|
||||
case sqliteDB != nil:
|
||||
return local.SQLiteProviderOptions{DB: sqliteDB}, nil
|
||||
default:
|
||||
return nil, errors.New("one of Postgres and SQLite must be set")
|
||||
}
|
||||
}
|
||||
|
||||
// getProviderOption wraps the shared provider options in the host option the local host expects
|
||||
func (o *NewActorsOpts) getProviderOption() (local.HostOption, error) {
|
||||
providerOpts, err := ActorsProviderOptions(o.Postgres, o.SQLite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch v := providerOpts.(type) {
|
||||
case postgres.PostgresProviderOptions:
|
||||
return local.WithPostgresProvider(v), nil
|
||||
case local.SQLiteProviderOptions:
|
||||
return local.WithSQLiteProvider(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider options type: %T", providerOpts)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
// In test mode, we do not register anything
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/haconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
if pg != nil {
|
||||
defer func() {
|
||||
// Close the database connection pool only after the shutdown functions have run: some of them (e.g. releasing the application lock) still need to query the database.
|
||||
// Close the database connection pool only after the shutdown functions have run: some of them (e.g. the actor host deregistering itself from the cluster) still need to query the database.
|
||||
pg.Close()
|
||||
}()
|
||||
}
|
||||
@@ -55,6 +56,13 @@ func Bootstrap(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to initialize instance ID: %w", err)
|
||||
}
|
||||
|
||||
// Enforce that the HA mode setting has not changed since the cluster was created
|
||||
// The value is fixed on first startup and stored in the "kv" table; a mismatch means the operator changed it against an existing database, which is unsupported, so we stop here rather than run with an inconsistent configuration
|
||||
err = haconfig.Check(ctx, db, common.EnvConfig.HAEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Init storage
|
||||
fileStorage, err := InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
@@ -104,29 +112,8 @@ func Bootstrap(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
services = append(services, svc.appLockService.RunRenewal)
|
||||
|
||||
// Acquire the lock from the app lock service
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
}
|
||||
|
||||
shutdowns.Add(func(shutdownCtx context.Context) error {
|
||||
sErr := svc.appLockService.Release(shutdownCtx)
|
||||
if sErr != nil {
|
||||
return fmt.Errorf("failed to release application lock: %w", sErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Single-replica enforcement now comes from the actor host itself
|
||||
// With HA disabled it registers with a host limit of 1, so a second replica fails to register and the process exits with ErrClusterFull, which is translated to a friendly message where the services run below
|
||||
|
||||
// Register scheduled jobs, only in non-test mode
|
||||
if common.EnvConfig.AppEnv != "test" {
|
||||
@@ -151,7 +138,9 @@ func Bootstrap(ctx context.Context) error {
|
||||
// Run all background services
|
||||
// This call blocks until the context is canceled
|
||||
err = servicerunner.NewServiceRunner(services...).Run(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, components.ErrClusterFull) {
|
||||
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas requires enabling HA mode, which is not yet supported")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to run services: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.appConfigService, svc.jwtService, svc.ldapService, 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -35,7 +35,6 @@ type services struct {
|
||||
ldapService *service.LdapService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
|
||||
apiKeyModule *apikey.Module
|
||||
@@ -66,7 +65,6 @@ func initServices(
|
||||
|
||||
svc.fileStorage = fileStorage
|
||||
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
|
||||
svc.appLockService = service.NewAppLockService(db)
|
||||
|
||||
svc.emailService, err = service.NewEmailService(db)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,15 +3,18 @@ package cmds
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/clusteradmin"
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -38,7 +41,7 @@ func init() {
|
||||
|
||||
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
|
||||
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
|
||||
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
|
||||
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire exclusive access by terminating any running Pocket ID instance")
|
||||
|
||||
rootCmd.AddCommand(importCmd)
|
||||
}
|
||||
@@ -73,23 +76,50 @@ func runImport(ctx context.Context, flags importFlags) error {
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
db, _, err := bootstrap.ConnectDatabase(ctx)
|
||||
// Connect to the database without running migrations: the import re-creates the Pocket ID schema itself
|
||||
db, pg, err := bootstrap.ConnectDatabase(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
|
||||
// The cluster admin talks to the same database as the actor host, so build its provider options the same way the host does
|
||||
var sqliteDB *sql.DB
|
||||
if pg == nil {
|
||||
sqliteDB, err = db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sql.DB connection: %w", err)
|
||||
}
|
||||
}
|
||||
providerOpts, err := bootstrap.ActorsProviderOptions(pg, sqliteDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage, err := bootstrap.InitStorage(ctx, db)
|
||||
// Take exclusive access to the cluster so no Pocket ID replica is running while we overwrite the database
|
||||
release, lost, err := acquireExclusiveAccess(ctx, providerOpts, flags.ForcefullyAcquireLock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Abort the import if exclusive access is lost partway through (for example if the lease can no longer be renewed)
|
||||
importCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-lost:
|
||||
cancel()
|
||||
case <-importCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
storage, err := bootstrap.InitStorage(importCtx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
|
||||
importService := service.NewImportService(db, storage)
|
||||
err = importService.ImportFromZip(ctx, &zipReader.Reader)
|
||||
err = importService.ImportFromZip(importCtx, &zipReader.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to import data from zip: %w", err)
|
||||
}
|
||||
@@ -98,41 +128,45 @@ func runImport(ctx context.Context, flags importFlags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
||||
// Check if the kv table exists, in case we are starting from an empty database
|
||||
exists, err := utils.DBTableExists(db, "kv")
|
||||
// acquireExclusiveAccess takes an exclusive-access lease on the cluster so the import can safely overwrite the database.
|
||||
//
|
||||
// It returns a release function that must be called once the import is done, and a channel that is closed if the lease is lost while it is held.
|
||||
func acquireExclusiveAccess(ctx context.Context, providerOpts components.ProviderOptions, force bool) (release func(), lost <-chan struct{}, err error) {
|
||||
// New initializes the provider, applying the actor host's schema migrations, so this also works against a brand-new (empty) database
|
||||
admin, err := clusteradmin.New(ctx, providerOpts, clusteradmin.Options{
|
||||
// Match the actor host so the admin waits the right amount of time for hosts to drain
|
||||
HostHealthCheckDeadline: bootstrap.ActorsHostHealthCheckDeadline(common.EnvConfig.HAEnabled),
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if kv table exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
|
||||
// In either case, there's no lock to acquire
|
||||
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
|
||||
return nil
|
||||
return nil, nil, fmt.Errorf("failed to create cluster admin: %w", err)
|
||||
}
|
||||
|
||||
// Note that we do not call a deferred Release if the data was imported
|
||||
// This is because we are overriding the contents of the database, so the lock is automatically lost
|
||||
appLockService := service.NewAppLockService(db)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
waitUntil, err := appLockService.Acquire(opCtx, force)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
//nolint:staticcheck
|
||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
lost, err = admin.AcquireExclusive(ctx, clusteradmin.AcquireOptions{Force: force})
|
||||
if err != nil {
|
||||
_ = admin.Close()
|
||||
switch {
|
||||
case errors.Is(err, components.ErrHostsConnected):
|
||||
//nolint:staticcheck
|
||||
return nil, nil, errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
case errors.Is(err, components.ErrExclusiveHeld):
|
||||
return nil, nil, errors.New("another exclusive operation, such as another import, is already in progress; please wait for it to complete and try again")
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("failed to acquire exclusive access: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
release = func() {
|
||||
// The import preserves the actor host's "francis_" tables, including the lease row, so the lease must be released explicitly
|
||||
// Detach from ctx so the release still runs even if the import was canceled
|
||||
releaseCtx, cancelRelease := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancelRelease()
|
||||
if rerr := admin.ReleaseExclusive(releaseCtx); rerr != nil {
|
||||
slog.WarnContext(ctx, "Failed to release exclusive access", slog.Any("error", rerr))
|
||||
}
|
||||
_ = admin.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
return release, lost, nil
|
||||
}
|
||||
|
||||
func askForConfirmation() (bool, error) {
|
||||
|
||||
@@ -85,6 +85,10 @@ type EnvConfigSchema struct {
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
// HAEnabled turns on high-availability mode, allowing more than one replica of Pocket ID to run against the same database at once
|
||||
// It is intentionally not bound to an environment variable while HA support is still being completed, so it stays false and the actor host enforces a single replica
|
||||
HAEnabled bool
|
||||
|
||||
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,167 +3,145 @@
|
||||
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.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
if err := tc.TestService.ResetApplicationImages(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
52
backend/internal/haconfig/haconfig.go
Normal file
52
backend/internal/haconfig/haconfig.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package haconfig enforces that the HA mode setting is fixed for the lifetime of a Pocket ID cluster.
|
||||
package haconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KVKey is the key in the "kv" table under which the cluster's HA mode setting is recorded.
|
||||
const KVKey = "ha_enabled"
|
||||
|
||||
// Check verifies that the HA mode setting has not changed since the Pocket ID cluster was created.
|
||||
//
|
||||
// On the first startup it atomically records the current value in the "kv" table; on every subsequent startup it reads back the stored value and compares it with the configured one, returning an error if they differ.
|
||||
//
|
||||
// The setting is immutable because it changes how replicas coordinate against the shared database, so switching it under an existing cluster would be unsafe. To change it, the data must be exported and re-imported into a new database.
|
||||
func Check(parentCtx context.Context, db *gorm.DB, haEnabled bool) error {
|
||||
desired := strconv.FormatBool(haEnabled)
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Atomically insert the current value if the row does not exist yet, then read back whatever is stored.
|
||||
// On a conflict the existing value is kept: the no-op self-update (value = kv.value) lets RETURNING return the current value, so the value fixed when the cluster was created is never overwritten here.
|
||||
// This is the same portable "INSERT ... ON CONFLICT ... RETURNING" pattern used for the instance ID, and is valid for both SQLite and Postgres.
|
||||
var stored string
|
||||
err := db.
|
||||
WithContext(ctx).
|
||||
Raw(
|
||||
`INSERT INTO kv (key, value)
|
||||
VALUES ('ha_enabled', ?)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = kv.value
|
||||
RETURNING value`,
|
||||
desired,
|
||||
).
|
||||
Scan(&stored).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load the HA mode setting from the database: %w", err)
|
||||
}
|
||||
|
||||
if stored != desired {
|
||||
return fmt.Errorf("the HA mode setting cannot be changed after a Pocket ID cluster has been created: the database was created with HAEnabled=%s, but the current configuration has HAEnabled=%s; to change this setting, export your data from Pocket ID and re-import it into a new database with the new value", stored, desired)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
111
backend/internal/haconfig/haconfig_test.go
Normal file
111
backend/internal/haconfig/haconfig_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package haconfig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// readHAEnabled returns the value stored in the kv table under the "ha_enabled" key, together with the number of rows that match (to detect duplicates)
|
||||
func readHAEnabled(t *testing.T, db *gorm.DB) (value string, count int) {
|
||||
t.Helper()
|
||||
var rows []model.KV
|
||||
err := db.Where("key = ?", KVKey).Find(&rows).Error
|
||||
require.NoError(t, err)
|
||||
if len(rows) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
require.NotNil(t, rows[0].Value, "ha_enabled value should not be NULL")
|
||||
return *rows[0].Value, len(rows)
|
||||
}
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
t.Run("records the value on first startup", func(t *testing.T) {
|
||||
for _, haEnabled := range []bool{false, true} {
|
||||
t.Run("HAEnabled="+boolStr(haEnabled), func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
err := Check(t.Context(), db, haEnabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, count := readHAEnabled(t, db)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, boolStr(haEnabled), stored)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("succeeds when the value is unchanged on a later startup", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
require.NoError(t, Check(t.Context(), db, true))
|
||||
|
||||
// A second startup with the same value must succeed and not create a duplicate row
|
||||
require.NoError(t, Check(t.Context(), db, true))
|
||||
|
||||
stored, count := readHAEnabled(t, db)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, "true", stored)
|
||||
})
|
||||
|
||||
t.Run("fails when the value changed, without overwriting the stored value", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// The cluster was created with HA disabled
|
||||
require.NoError(t, Check(t.Context(), db, false))
|
||||
|
||||
// Starting up with HA enabled must be rejected
|
||||
err := Check(t.Context(), db, true)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot be changed")
|
||||
|
||||
// The stored value must not have been changed by the rejected startup
|
||||
stored, count := readHAEnabled(t, db)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, "false", stored)
|
||||
|
||||
// Starting up again with the original value still works
|
||||
require.NoError(t, Check(t.Context(), db, false))
|
||||
})
|
||||
|
||||
t.Run("concurrent first startups converge on a single value", func(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
|
||||
const parallel = 25
|
||||
errs := make([]error, parallel)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(parallel)
|
||||
for i := range parallel {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[i] = Check(t.Context(), db, true)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
require.NoErrorf(t, err, "goroutine %d errored", i)
|
||||
}
|
||||
|
||||
// Only a single row must have been persisted
|
||||
stored, count := readHAEnabled(t, db)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, "true", stored)
|
||||
})
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLockUnavailable = errors.New("lock is already held by another process")
|
||||
ErrLockLost = errors.New("lock ownership lost")
|
||||
)
|
||||
|
||||
const (
|
||||
ttl = 30 * time.Second
|
||||
renewInterval = 20 * time.Second
|
||||
renewRetries = 3
|
||||
lockKey = "application_lock"
|
||||
)
|
||||
|
||||
type AppLockService struct {
|
||||
db *gorm.DB
|
||||
lockID string
|
||||
processID int64
|
||||
hostID string
|
||||
}
|
||||
|
||||
func NewAppLockService(db *gorm.DB) *AppLockService {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "unknown-host"
|
||||
}
|
||||
|
||||
return &AppLockService{
|
||||
db: db,
|
||||
processID: int64(os.Getpid()),
|
||||
hostID: host,
|
||||
lockID: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
type lockValue struct {
|
||||
ProcessID int64 `json:"process_id"`
|
||||
HostID string `json:"host_id"`
|
||||
LockID string `json:"lock_id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (lv *lockValue) Marshal() (string, error) {
|
||||
data, err := json.Marshal(lv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (lv *lockValue) Unmarshal(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), lv)
|
||||
}
|
||||
|
||||
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
||||
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
||||
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
||||
ctx, span := tracing.Start(ctx, "pocketid.applock.acquire", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
waitUntil, err = s.acquire(ctx, force)
|
||||
if err != nil {
|
||||
tracing.End(span, err)
|
||||
return waitUntil, err
|
||||
}
|
||||
|
||||
span.End()
|
||||
return waitUntil, nil
|
||||
}
|
||||
|
||||
func (s *AppLockService) acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
err = tx.Error
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("begin lock transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var prevLockRaw string
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.KV{}).
|
||||
Where("key = ?", lockKey).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("value").
|
||||
Scan(&prevLockRaw).
|
||||
Error
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("query existing lock: %w", err)
|
||||
}
|
||||
|
||||
var prevLock lockValue
|
||||
if prevLockRaw != "" {
|
||||
err = prevLock.Unmarshal(prevLockRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
nowUnix := now.Unix()
|
||||
|
||||
value := lockValue{
|
||||
ProcessID: s.processID,
|
||||
HostID: s.hostID,
|
||||
LockID: s.lockID,
|
||||
ExpiresAt: now.Add(ttl).Unix(),
|
||||
}
|
||||
raw, err := value.Marshal()
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("encode lock value: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
INSERT INTO kv (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value
|
||||
WHERE (json_extract(kv.value, '$.expires_at') < ?) OR ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
INSERT INTO kv (key, value)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value
|
||||
WHERE ((kv.value::json->>'expires_at')::bigint < $3) OR ($4::boolean IS TRUE)
|
||||
`
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
res := tx.WithContext(ctx).Exec(query, lockKey, raw, nowUnix, force)
|
||||
if res.Error != nil {
|
||||
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
||||
}
|
||||
|
||||
// If there is a lock that is not expired and force is false, no rows will be affected
|
||||
if res.RowsAffected == 0 {
|
||||
err = ErrLockUnavailable
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
if force && prevLock.ExpiresAt > nowUnix && prevLock.LockID != s.lockID {
|
||||
waitUntil = time.Unix(prevLock.ExpiresAt, 0)
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
}
|
||||
wait := time.Until(waitUntil)
|
||||
if wait > 0 {
|
||||
attrs = append(attrs, slog.Duration("wait_before_proceeding", wait))
|
||||
}
|
||||
slog.InfoContext(ctx, "Acquired application lock", attrs...)
|
||||
|
||||
return waitUntil, nil
|
||||
}
|
||||
|
||||
// RunRenewal keeps renewing the lock until the context is canceled.
|
||||
func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
||||
ticker := time.NewTicker(renewInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
renewCtx, span := tracing.Start(ctx, "pocketid.applock.renew", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
err := s.renew(renewCtx)
|
||||
tracing.End(span, err)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renew lock: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release releases the lock if it is held by this process.
|
||||
func (s *AppLockService) Release(ctx context.Context) (err error) {
|
||||
ctx, span := tracing.Start(ctx, "pocketid.applock.release", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
err = s.release(ctx)
|
||||
if err != nil {
|
||||
tracing.End(span, err)
|
||||
return err
|
||||
}
|
||||
|
||||
span.End()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppLockService) release(ctx context.Context) error {
|
||||
db, err := s.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
DELETE FROM kv
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.lock_id') = ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
DELETE FROM kv
|
||||
WHERE key = $1
|
||||
AND value::json->>'lock_id' = $2
|
||||
`
|
||||
default:
|
||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("release lock failed: %w", err)
|
||||
}
|
||||
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
// Treat this as a warning, not an error
|
||||
slog.WarnContext(ctx, "Application lock not held by this process, cannot release",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Released application lock",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
||||
func (s *AppLockService) renew(ctx context.Context) error {
|
||||
db, err := s.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= renewRetries; attempt++ {
|
||||
now := time.Now()
|
||||
nowUnix := now.Unix()
|
||||
expiresAt := now.Add(ttl).Unix()
|
||||
|
||||
value := lockValue{
|
||||
LockID: s.lockID,
|
||||
ProcessID: s.processID,
|
||||
HostID: s.hostID,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
raw, err := value.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode lock value: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
UPDATE kv
|
||||
SET value = ?
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.lock_id') = ?
|
||||
AND json_extract(value, '$.expires_at') > ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
UPDATE kv
|
||||
SET value = $1
|
||||
WHERE key = $2
|
||||
AND value::json->>'lock_id' = $3
|
||||
AND ((value::json->>'expires_at')::bigint > $4)
|
||||
`
|
||||
default:
|
||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix)
|
||||
cancel()
|
||||
|
||||
// Query succeeded, but may have updated 0 rows
|
||||
if err == nil {
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||
}
|
||||
|
||||
// If no rows were updated, we lost the lock
|
||||
if count == 0 {
|
||||
return ErrLockLost
|
||||
}
|
||||
|
||||
// All good
|
||||
slog.DebugContext(ctx, "Renewed application lock",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
slog.Duration("duration", time.Since(now)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we're here, we have an error that can be retried
|
||||
slog.DebugContext(ctx, "Application lock renewal attempt failed",
|
||||
slog.Any("error", err),
|
||||
slog.Duration("duration", time.Since(now)),
|
||||
)
|
||||
lastErr = fmt.Errorf("lock renewal failed: %w", err)
|
||||
|
||||
// Wait before next attempt or cancel if context is done
|
||||
if attempt < renewRetries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
|
||||
t.Helper()
|
||||
|
||||
return &AppLockService{
|
||||
db: db,
|
||||
processID: 1,
|
||||
hostID: "test-host",
|
||||
lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
|
||||
}
|
||||
}
|
||||
|
||||
func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := value.Marshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func readLockValue(t *testing.T, db *gorm.DB) lockValue {
|
||||
t.Helper()
|
||||
|
||||
var row model.KV
|
||||
err := db.Take(&row, "key = ?", lockKey).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, row.Value)
|
||||
|
||||
var value lockValue
|
||||
err = value.Unmarshal(*row.Value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
tx := db.Begin()
|
||||
require.NoError(t, tx.Error)
|
||||
|
||||
// Keep a write transaction open to block other queries.
|
||||
err := tx.Exec(
|
||||
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
|
||||
lockKey,
|
||||
`{"expires_at":0}`,
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestAppLockServiceAcquire(t *testing.T) {
|
||||
t.Run("creates new lock when none exists", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, stored.ProcessID)
|
||||
require.Equal(t, service.hostID, stored.HostID)
|
||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.ErrorIs(t, err, ErrLockUnavailable)
|
||||
|
||||
current := readLockValue(t, db)
|
||||
require.Equal(t, existing, current)
|
||||
})
|
||||
|
||||
t.Run("force acquisition steals lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
insertLock(t, db, lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, stored.ProcessID)
|
||||
require.Equal(t, service.hostID, stored.HostID)
|
||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||
})
|
||||
|
||||
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
LockID: "other-lock-id",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
waitUntil, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
|
||||
})
|
||||
|
||||
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
insertLock(t, db, lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
LockID: service.lockID,
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
})
|
||||
|
||||
waitUntil, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, waitUntil.IsZero())
|
||||
})
|
||||
|
||||
t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
err := db.Create(&model.KV{Key: lockKey, Value: new("this-is-not-json")}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Acquire(context.Background(), false)
|
||||
require.ErrorContains(t, err, "decode existing lock value")
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := service.Acquire(ctx, false)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorContains(t, err, "begin lock transaction")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRelease(t *testing.T) {
|
||||
t.Run("removes owned lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Release(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
var row model.KV
|
||||
err = db.Take(&row, "key = ?", lockKey).Error
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
})
|
||||
|
||||
t.Run("ignores lock held by another owner", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 2,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
err := service.Release(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, existing, stored)
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err = service.Release(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorContains(t, err, "release lock failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRenew(t *testing.T) {
|
||||
t.Run("extends expiration when lock is still owned", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
before := readLockValue(t, db)
|
||||
|
||||
err = service.renew(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
after := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, after.ProcessID)
|
||||
require.Equal(t, service.hostID, after.HostID)
|
||||
require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
err := service.renew(context.Background())
|
||||
require.ErrorIs(t, err, ErrLockLost)
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate a different process taking the lock.
|
||||
newOwner := lockValue{
|
||||
ProcessID: 9,
|
||||
HostID: "stolen-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
raw, marshalErr := newOwner.Marshal()
|
||||
require.NoError(t, marshalErr)
|
||||
updateErr := db.Model(&model.KV{}).
|
||||
Where("key = ?", lockKey).
|
||||
Update("value", raw).Error
|
||||
require.NoError(t, updateErr)
|
||||
|
||||
err = service.renew(context.Background())
|
||||
require.ErrorIs(t, err, ErrLockLost)
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err = service.renew(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
})
|
||||
}
|
||||
@@ -45,7 +45,6 @@ type TestService struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
ldapService *LdapService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *AppLockService
|
||||
externalIdPKey jwk.Key
|
||||
}
|
||||
|
||||
@@ -56,13 +55,12 @@ 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, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
jwtService: jwtService,
|
||||
ldapService: ldapService,
|
||||
appLockService: appLockService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
err := s.initExternalIdP()
|
||||
@@ -220,6 +218,9 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
LogoutCallbackURLs: model.UrlList{"http://tailscale.localhost/auth/logout/callback"},
|
||||
IsGroupRestricted: true,
|
||||
CreatedByID: new(users[0].ID),
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -282,6 +283,61 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
farFuture := datatype.DateTime(time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
oauth2Session := oidc.OAuth2Session{
|
||||
Base: model.Base{
|
||||
ID: "551ab785-c830-47d3-8a07-60c9f3bb4859",
|
||||
},
|
||||
Kind: "access_token",
|
||||
Key: "cross-database-test-session",
|
||||
RequestID: "cross-database-test-request",
|
||||
AccessTokenSignature: "",
|
||||
Active: true,
|
||||
RequestData: `{"request":"value"}`,
|
||||
ExpiresAt: &farFuture,
|
||||
}
|
||||
if err := tx.Create(&oauth2Session).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Table("oauth2_jtis").Create(map[string]any{
|
||||
"id": "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
|
||||
"created_at": datatype.DateTime(time.Now()),
|
||||
"jti": "cross-database-test-jti",
|
||||
"expires_at": farFuture,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interactionSession := oidc.InteractionSession{
|
||||
Base: model.Base{
|
||||
ID: "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
|
||||
},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: oidcClients[0].ID,
|
||||
UserID: new(users[0].ID),
|
||||
ConsentRequired: true,
|
||||
RequestedAt: farFuture,
|
||||
Parameters: oidc.InteractionSessionParameters{
|
||||
"client_id": oidcClients[0].ID,
|
||||
},
|
||||
}
|
||||
if err := tx.Create(&interactionSession).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reauthenticationToken := webauthn.ReauthenticationToken{
|
||||
Base: model.Base{
|
||||
ID: "71839ace-d978-4e6f-8fb1-b8648a21031b",
|
||||
},
|
||||
Token: "cross-database-reauthentication-token",
|
||||
ExpiresAt: farFuture,
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&reauthenticationToken).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
@@ -656,11 +712,6 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TestService) ResetLock(ctx context.Context) error {
|
||||
_, err := s.appLockService.Acquire(ctx, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncLdap triggers an LDAP synchronization
|
||||
func (s *TestService) SyncLdap(ctx context.Context) error {
|
||||
dbConfig, err := s.appConfigService.GetConfig(ctx)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/haconfig"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -109,10 +110,10 @@ func (s *ExportService) dumpTable(table string, types utils.DBSchemaTableTypes,
|
||||
rowMap[col] = vals[i]
|
||||
}
|
||||
|
||||
// Skip the app lock row in the kv table
|
||||
// Skip the HA mode row in the kv table: it records the mode the cluster was created with, and is re-established from config (not the export) when the data is imported into a new database
|
||||
if table == "kv" {
|
||||
keyPtr, ok := rowMap["key"].(*string)
|
||||
if ok && keyPtr != nil && *keyPtr == lockKey {
|
||||
if ok && keyPtr != nil && *keyPtr == haconfig.KVKey {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/haconfig"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestExportExcludesActorHostTables verifies that an export does not dump the actor host's own "francis_" tables
|
||||
@@ -28,3 +31,29 @@ func TestExportExcludesActorHostTables(t *testing.T) {
|
||||
require.Falsef(t, strings.HasPrefix(table, "francis_"), "export must not include actor host table %q", table)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportOmitsHAModeKV verifies that an export omits the "ha_enabled" row from the kv table
|
||||
// The HA mode is a per-cluster setting re-established from config on import, so carrying it in the export would pin an imported cluster to the exporter's mode
|
||||
func TestExportOmitsHAModeKV(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Seed the kv table with the HA mode row (must be omitted) and another row (must be kept)
|
||||
haValue := "true"
|
||||
instanceValue := "instance-123"
|
||||
require.NoError(t, db.Create(&model.KV{Key: haconfig.KVKey, Value: &haValue}).Error)
|
||||
require.NoError(t, db.Create(&model.KV{Key: "instance_id", Value: &instanceValue}).Error)
|
||||
|
||||
export, err := NewExportService(db, nil).extractDatabase()
|
||||
require.NoError(t, err)
|
||||
|
||||
keys := make(map[string]bool)
|
||||
for _, row := range export.Tables["kv"] {
|
||||
keyPtr, ok := row["key"].(*string)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, keyPtr)
|
||||
keys[*keyPtr] = true
|
||||
}
|
||||
|
||||
require.NotContains(t, keys, haconfig.KVKey, "export must omit the HA mode row")
|
||||
require.Contains(t, keys, "instance_id", "export must keep other kv rows")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -589,32 +587,20 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.OidcClient{}).
|
||||
Preload("UserAuthorizedOidcClients", "user_id = ?", userID)
|
||||
|
||||
// If user has no groups, only return clients with no allowed user groups
|
||||
if len(userGroupIDs) == 0 {
|
||||
query = query.Where(`NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
|
||||
} else {
|
||||
query = query.Where(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs)
|
||||
}
|
||||
Preload("UserAuthorizedOidcClients", "user_id = ?", userID).
|
||||
Where(`oidc_clients.is_group_restricted = ? OR EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs)
|
||||
|
||||
var clients []model.OidcClient
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -9,12 +9,12 @@ 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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
@@ -458,11 +458,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 +474,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 +514,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 +528,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 = ""
|
||||
@@ -541,3 +539,45 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, fetched.Description)
|
||||
}
|
||||
|
||||
func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowedGroup := model.UserGroup{Name: "allowed", FriendlyName: "Allowed"}
|
||||
otherGroup := model.UserGroup{Name: "other", FriendlyName: "Other"}
|
||||
require.NoError(t, db.Create(&allowedGroup).Error)
|
||||
require.NoError(t, db.Create(&otherGroup).Error)
|
||||
|
||||
userWithGroup := model.User{Username: "with-group", UserGroups: []model.UserGroup{allowedGroup}}
|
||||
userWithoutGroup := model.User{Username: "without-group"}
|
||||
require.NoError(t, db.Create(&userWithGroup).Error)
|
||||
require.NoError(t, db.Create(&userWithoutGroup).Error)
|
||||
|
||||
clients := []model.OidcClient{
|
||||
{Name: "Unrestricted", CallbackURLs: model.UrlList{"https://unrestricted.example.com/callback"}},
|
||||
{Name: "Restricted without groups", CallbackURLs: model.UrlList{"https://empty.example.com/callback"}, IsGroupRestricted: true},
|
||||
{Name: "Restricted to user group", CallbackURLs: model.UrlList{"https://allowed.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{allowedGroup}},
|
||||
{Name: "Restricted to other group", CallbackURLs: model.UrlList{"https://other.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{otherGroup}},
|
||||
}
|
||||
for i := range clients {
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
}
|
||||
|
||||
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"Unrestricted", "Restricted to user group"}, accessibleClientNames(groupClients))
|
||||
|
||||
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
|
||||
}
|
||||
|
||||
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
|
||||
names := make([]string, len(clients))
|
||||
for i := range clients {
|
||||
names[i] = clients[i].Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"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 {
|
||||
@@ -55,56 +53,11 @@ func New(deps Dependencies) *Module {
|
||||
|
||||
// RegisterRoutes mounts the signup and signup-token management endpoints
|
||||
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
|
||||
func (m *Module) RegisterRoutes(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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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[") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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+"=")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on PostgreSQL
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on PostgreSQL because its OAuth storage types already match the export format
|
||||
@@ -0,0 +1,151 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE reauthentication_tokens_old (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO reauthentication_tokens_old (
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
FROM reauthentication_tokens;
|
||||
|
||||
DROP TABLE reauthentication_tokens;
|
||||
ALTER TABLE reauthentication_tokens_old RENAME TO reauthentication_tokens;
|
||||
|
||||
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens (token);
|
||||
CREATE INDEX idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_sessions_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
access_token_signature TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
request_data TEXT NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_sessions_old (
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
request_data,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
CAST(request_data AS TEXT),
|
||||
expires_at
|
||||
FROM oauth2_sessions;
|
||||
|
||||
DROP TABLE oauth2_sessions;
|
||||
ALTER TABLE oauth2_sessions_old RENAME TO oauth2_sessions;
|
||||
|
||||
CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
|
||||
CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
|
||||
CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_jtis_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_jtis_old (
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
FROM oauth2_jtis;
|
||||
|
||||
DROP TABLE oauth2_jtis;
|
||||
ALTER TABLE oauth2_jtis_old RENAME TO oauth2_jtis;
|
||||
|
||||
CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
|
||||
|
||||
CREATE TABLE interaction_sessions_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
consent_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
scopes TEXT NOT NULL DEFAULT '[]',
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_at INTEGER NOT NULL,
|
||||
reauthenticated_at INTEGER,
|
||||
parameters TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
INSERT INTO interaction_sessions_old (
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
scopes,
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
parameters
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
CAST(scopes AS TEXT),
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
CAST(parameters AS TEXT)
|
||||
FROM interaction_sessions;
|
||||
|
||||
DROP TABLE interaction_sessions;
|
||||
ALTER TABLE interaction_sessions_old RENAME TO interaction_sessions;
|
||||
|
||||
CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id);
|
||||
CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -0,0 +1,152 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Align JSON and timestamp column types with the export format used for PostgreSQL
|
||||
CREATE TABLE reauthentication_tokens_new (
|
||||
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
|
||||
);
|
||||
|
||||
INSERT INTO reauthentication_tokens_new (
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
FROM reauthentication_tokens;
|
||||
|
||||
DROP TABLE reauthentication_tokens;
|
||||
ALTER TABLE reauthentication_tokens_new RENAME TO reauthentication_tokens;
|
||||
|
||||
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens (token);
|
||||
CREATE INDEX idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_sessions_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
access_token_signature TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
request_data BLOB NOT NULL,
|
||||
expires_at DATETIME
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_sessions_new (
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
request_data,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
CAST(request_data AS BLOB),
|
||||
expires_at
|
||||
FROM oauth2_sessions;
|
||||
|
||||
DROP TABLE oauth2_sessions;
|
||||
ALTER TABLE oauth2_sessions_new RENAME TO oauth2_sessions;
|
||||
|
||||
CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
|
||||
CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
|
||||
CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_jtis_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_jtis_new (
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
FROM oauth2_jtis;
|
||||
|
||||
DROP TABLE oauth2_jtis;
|
||||
ALTER TABLE oauth2_jtis_new RENAME TO oauth2_jtis;
|
||||
|
||||
CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
|
||||
|
||||
CREATE TABLE interaction_sessions_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
consent_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
scopes BLOB NOT NULL DEFAULT X'5B5D',
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_at DATETIME NOT NULL,
|
||||
reauthenticated_at DATETIME,
|
||||
parameters BLOB NOT NULL DEFAULT X'7B7D'
|
||||
);
|
||||
|
||||
INSERT INTO interaction_sessions_new (
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
scopes,
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
parameters
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
CAST(scopes AS BLOB),
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
CAST(parameters AS BLOB)
|
||||
FROM interaction_sessions;
|
||||
|
||||
DROP TABLE interaction_sessions;
|
||||
ALTER TABLE interaction_sessions_new RENAME TO interaction_sessions;
|
||||
|
||||
CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id);
|
||||
CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,11 @@
|
||||
placeholder={m.code()}
|
||||
aria-label={m.code()}
|
||||
bind:value={code}
|
||||
autofocus
|
||||
type="text"
|
||||
/>
|
||||
{:else}
|
||||
<InputOTP.Root maxlength={6} bind:value={code}>
|
||||
<InputOTP.Root maxlength={6} bind:value={code} autofocus>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells as cell}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4 overflow-hidden pb-2">
|
||||
<div class="flex w-full flex-col gap-4 overflow-hidden pb-2 px-2">
|
||||
<FadeWrapper>
|
||||
<EmailVerificationStateBox />
|
||||
{@render children()}
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -211,8 +211,8 @@ importers:
|
||||
tests:
|
||||
dependencies:
|
||||
adm-zip:
|
||||
specifier: ^0.5.17
|
||||
version: 0.5.18
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
@@ -1476,9 +1476,9 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adm-zip@0.5.18:
|
||||
resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==}
|
||||
engines: {node: '>=12.0'}
|
||||
adm-zip@0.6.0:
|
||||
resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
@@ -4146,7 +4146,7 @@ snapshots:
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
adm-zip@0.5.18: {}
|
||||
adm-zip@0.6.0: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
"prettier": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17"
|
||||
"adm-zip": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260708130000,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"version": 20260722120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
"oidc_clients",
|
||||
"signup_tokens",
|
||||
"apis",
|
||||
"api_permissions",
|
||||
"oidc_clients_allowed_api_permissions"
|
||||
],
|
||||
"tables": {
|
||||
"apis": [
|
||||
{
|
||||
@@ -229,6 +237,10 @@
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||
@@ -264,6 +276,52 @@
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
}
|
||||
],
|
||||
"oauth2_jtis": [
|
||||
{
|
||||
"id": "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"jti": "cross-database-test-jti",
|
||||
"expires_at": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"oauth2_sessions": [
|
||||
{
|
||||
"id": "551ab785-c830-47d3-8a07-60c9f3bb4859",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"kind": "access_token",
|
||||
"key": "cross-database-test-session",
|
||||
"request_id": "cross-database-test-request",
|
||||
"access_token_signature": "",
|
||||
"active": true,
|
||||
"request_data": "eyJyZXF1ZXN0IjoidmFsdWUifQ==",
|
||||
"expires_at": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"interaction_sessions": [
|
||||
{
|
||||
"id": "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"consent_required": true,
|
||||
"reauthentication_required": false,
|
||||
"authentication_required": false,
|
||||
"account_selection_required": false,
|
||||
"scopes": "WyJvcGVuaWQiXQ==",
|
||||
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"requested_at": "2099-01-01T00:00:00Z",
|
||||
"reauthenticated_at": null,
|
||||
"parameters": "eyJjbGllbnRfaWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUifQ=="
|
||||
}
|
||||
],
|
||||
"reauthentication_tokens": [
|
||||
{
|
||||
"id": "71839ace-d978-4e6f-8fb1-b8648a21031b",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"token": "cross-database-reauthentication-token",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
}
|
||||
],
|
||||
"signup_tokens": [
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
|
||||
@@ -59,7 +59,7 @@ test('Export via stdout', async ({ baseURL }) => {
|
||||
compareExports(exampleExportPath, stdoutExtractPath);
|
||||
});
|
||||
|
||||
test('Import', async () => {
|
||||
test('Import SQLite export', async () => {
|
||||
// Reset the backend without seeding
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
@@ -83,7 +83,7 @@ test('Import', async () => {
|
||||
compareExports(exampleExportPath, exportExtracted);
|
||||
});
|
||||
|
||||
test('Import via stdin', async () => {
|
||||
test('Import SQLite export via stdin', async () => {
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip');
|
||||
|
||||
Reference in New Issue
Block a user