Merge branch 'main' into feat/huma-api-docs

This commit is contained in:
Elias Schneider
2026-07-26 20:33:43 +02:00
60 changed files with 9046 additions and 1313 deletions

View File

@@ -33,7 +33,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v6.5.0
with:
node-version: 24
cache: pnpm
@@ -82,11 +82,11 @@ jobs:
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
- name: Binary attestation
uses: actions/attest@v4.1.1
uses: actions/attest@v4.2.0
with:
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest@v4.1.1
uses: actions/attest@v4.2.0
with:
subject-checksums: ./dist/digests.txt

View File

@@ -45,7 +45,7 @@ jobs:
uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v6.5.0
with:
node-version: 24
cache: "pnpm"

View File

@@ -28,7 +28,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v6.5.0
with:
node-version: 24
cache: pnpm
@@ -76,12 +76,12 @@ jobs:
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
- name: Binary attestation
uses: actions/attest@v4.1.1
uses: actions/attest@v4.2.0
with:
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest@v4.1.1
uses: actions/attest@v4.2.0
with:
subject-checksums: ./dist/digests.txt

View File

@@ -41,7 +41,7 @@ jobs:
uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v6.5.0
with:
node-version: 24
cache: "pnpm"

View File

@@ -4,10 +4,10 @@ go 1.26.5
require (
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.29
github.com/aws/aws-sdk-go-v2/credentials v1.19.28
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/smithy-go v1.27.3
github.com/aws/aws-sdk-go-v2/config v1.32.30
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2
github.com/aws/smithy-go v1.27.4
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
@@ -20,12 +20,13 @@ require (
github.com/gin-gonic/gin v1.12.0
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-ldap/ldap/v3 v3.4.14
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/go-kit v0.0.0-20260708054611-e276b65dd3be
github.com/italypaleale/francis v0.1.0-beta.15
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86
github.com/italypaleale/go-sql-utils v0.2.4
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
@@ -33,8 +34,8 @@ require (
github.com/lestrrat-go/httprc/v3 v3.0.6
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/libtnb/sqlite v1.2.0
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.22
github.com/lmittmann/tint v1.2.0
github.com/mattn/go-isatty v0.0.23
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
@@ -60,14 +61,14 @@ require (
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
gorm.io/plugin/opentelemetry v0.1.16
modernc.org/sqlite v1.53.0
modernc.org/sqlite v1.54.0
)
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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
@@ -80,10 +81,10 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
@@ -110,7 +111,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
@@ -118,7 +119,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 +137,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 +173,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
@@ -228,13 +228,13 @@ require (
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
@@ -243,8 +243,8 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
modernc.org/libc v1.73.5 // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

View File

@@ -4,10 +4,10 @@ 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=
@@ -22,10 +22,10 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU=
github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
@@ -42,18 +42,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
@@ -161,8 +161,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -176,8 +176,8 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -259,16 +259,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/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/francis v0.1.0-beta.15 h1:yVFJCcD1pP91rIesAb06Gp1K1tPj0ruh6hha5YEhtRw=
github.com/italypaleale/francis v0.1.0-beta.15/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc=
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86 h1:719T7W8hLVjelch856Sern60QAPMn0fIE87i91YVcfw=
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86/go.mod h1:0Sy3bN3qnSy2kgcJ05A2CsP6os5wmLC9lPraFr+0jGk=
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
github.com/italypaleale/go-sql-utils v0.2.4/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
@@ -352,12 +352,12 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libtnb/sqlite v1.2.0 h1:XsA7jsXHH2qmFkTWoy5YCKJybzHzWQ0flqDFP5Y9Yto=
github.com/libtnb/sqlite v1.2.0/go.mod h1:O6vURH5fa5IgSmXd/qLAL2zSnYFUd7xSIumsmX3BrSI=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY=
github.com/lmittmann/tint v1.2.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg=
@@ -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=
@@ -622,8 +622,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
@@ -635,8 +635,8 @@ golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -657,8 +657,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -726,8 +726,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -769,12 +769,12 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI=
k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -783,8 +783,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -793,8 +793,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

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

View File

@@ -106,6 +106,9 @@ func Bootstrap(ctx context.Context) error {
}
services = append(services, svc.appLockService.RunRenewal)
// Migrate the pre-actor signup tokens into their actors, once the actor host is ready
services = append(services, actorsReady.Await(svc.userSignUpModule.RunSignupTokenMigration))
// Acquire the lock from the app lock service
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if errors.Is(err, service.ErrLockUnavailable) {

View File

@@ -17,7 +17,7 @@ import (
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)
testService, err := service.NewTestService(db, svc.actors, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)

View File

@@ -99,7 +99,7 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdown
Level: level,
})
} else {
handler = tint.NewHandler(os.Stdout, &tint.Options{
handler = tint.NewTextHandler(os.Stdout, &tint.Options{
TimeFormat: time.Stamp,
Level: level,
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),

View File

@@ -159,7 +159,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
)
controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule)
controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(api, authMiddleware, fileSizeLimitMiddleware, svc.appImagesService)
controller.NewAuditLogController(api, svc.auditLogService, authMiddleware)
@@ -172,6 +172,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
authMiddleware.Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitSignup),
)
svc.oneTimeAccessModule.RegisterRoutes(api,
authMiddleware.Huma(api),
authMiddleware.WithAdminNotRequired().Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken),
rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)

View File

@@ -14,6 +14,7 @@ import (
"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/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
@@ -21,28 +22,29 @@ import (
)
type services struct {
appConfigService *appconfig.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *appconfig.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
oneTimeAccessModule *onetimeaccess.Module
apiModule *api.Module
actors *local.Host
}
// Initializes all services
@@ -56,7 +58,9 @@ func initServices(
fileStorage storage.FileStorage,
scheduler *job.Scheduler,
) (svc *services, err error) {
svc = &services{}
svc = &services{
actors: actors,
}
// Init the app config service
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
@@ -132,14 +136,30 @@ func initServices(
return nil, fmt.Errorf("failed to create API key module: %w", err)
}
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
})
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create user signup module: %w", err)
}
svc.oneTimeAccessModule, err = onetimeaccess.New(onetimeaccess.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
UserProvider: svc.userService,
EmailSender: service.NewOneTimeAccessEmailSender(svc.emailService),
AppConfig: svc.appConfigService,
})
if err != nil {
return nil, fmt.Errorf("failed to create one-time access module: %w", err)
}
svc.versionService = service.NewVersionService(httpClient)

View File

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

View File

@@ -23,6 +23,11 @@ type oidcClientIDInput struct {
ID string `path:"id"`
}
type oidcClientSecretInput struct {
ID string `path:"id"`
Body *dto.OidcClientSecretDto `required:"false"`
}
type oidcClientListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
@@ -294,8 +299,12 @@ func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcCl
return mapOIDCClient(client)
}
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) {
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID)
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientSecretInput) (*httpapi.BodyOutput[map[string]string], error) {
body := dto.OidcClientSecretDto{}
if input.Body != nil {
body = *input.Body
}
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID, body)
if err != nil {
return nil, err
}

View File

@@ -10,20 +10,15 @@ import (
"github.com/danielgtaylor/huma/v2"
"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/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"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"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
type userListInput struct {
utils.ListRequestOptions
Search string `query:"search" required:"false"`
@@ -65,37 +60,10 @@ type currentUserPictureUploadInput struct {
RawBody huma.MultipartFormFiles[userPictureUploadForm]
}
type oneTimeAccessOwnInput struct {
Body dto.OneTimeAccessTokenCreateDto
}
type oneTimeAccessAdminInput struct {
ID string `path:"id"`
Body dto.OneTimeAccessTokenCreateDto
}
type oneTimeAccessEmailAdminInput struct {
ID string `path:"id"`
Body dto.OneTimeAccessEmailAsAdminDto
}
type oneTimeAccessEmailInput struct {
Body dto.OneTimeAccessEmailAsUnauthenticatedUserDto
}
type oneTimeAccessExchangeInput struct {
Token string `path:"token"`
}
type emailVerificationInput struct {
Body dto.EmailVerificationDto
}
type userCookieOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
Body dto.UserDto
}
type userPictureOutput struct {
ContentType string `header:"Content-Type"`
ContentLength int64 `header:"Content-Length"`
@@ -104,8 +72,8 @@ type userPictureOutput struct {
}
// NewUserController registers user management endpoints
func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *appconfig.AppConfigService) {
controller := &UserController{userService: userService, oneTimeAccessService: oneTimeAccessService, webAuthnService: webAuthnService, appConfigService: appConfigService}
func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, webAuthnService *webauthn.Module) {
controller := &UserController{appConfigService: appConfigService, userService: userService, webAuthnService: webAuthnService}
adminAuth := authMiddleware.Huma(api)
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
@@ -226,50 +194,6 @@ func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware,
DefaultStatus: http.StatusNoContent,
}, controller.updateCurrentUserProfilePictureHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "create-own-one-time-access-token",
Method: http.MethodPost,
Path: "/api/users/me/one-time-access-token",
Summary: "Create one-time access token for current user",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, controller.createOwnOneTimeAccessTokenHandler, userAuth)
httpapi.Register(api, huma.Operation{
OperationID: "create-user-one-time-access-token",
Method: http.MethodPost,
Path: "/api/users/{id}/one-time-access-token",
Summary: "Create one-time access token for user",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, controller.createAdminOneTimeAccessTokenHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "request-user-one-time-access-email",
Method: http.MethodPost,
Path: "/api/users/{id}/one-time-access-email",
Summary: "Request one-time access email for user",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, controller.requestOneTimeAccessEmailAsAdminHandler, adminAuth)
httpapi.Register(api, huma.Operation{
OperationID: "exchange-one-time-access-token",
Method: http.MethodPost,
Path: "/api/one-time-access-token/{token}",
Summary: "Exchange one-time access token",
Tags: []string{"Users"},
}, controller.exchangeOneTimeAccessTokenHandler, httpapi.WithMiddleware(rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken)))
httpapi.Register(api, huma.Operation{
OperationID: "request-one-time-access-email",
Method: http.MethodPost,
Path: "/api/one-time-access-email",
Summary: "Request one-time access email",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, controller.requestOneTimeAccessEmailAsUnauthenticatedUserHandler, httpapi.WithMiddleware(rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail)))
httpapi.Register(api, huma.Operation{
OperationID: "reset-user-profile-picture",
Method: http.MethodDelete,
@@ -308,10 +232,9 @@ func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware,
}
type UserController struct {
appConfigService *appconfig.AppConfigService
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *webauthn.Module
appConfigService *appconfig.AppConfigService
userService *service.UserService
webAuthnService *webauthn.Module
}
func (uc *UserController) getUserGroupsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.UserGroupDto], error) {
@@ -460,83 +383,6 @@ func (uc *UserController) updateProfilePicture(ctx context.Context, userID strin
return &httpapi.EmptyOutput{}, nil
}
func (uc *UserController) createOwnOneTimeAccessTokenHandler(ctx context.Context, _ *oneTimeAccessOwnInput) (*httpapi.BodyOutput[map[string]string], error) {
return uc.createOneTimeAccessToken(ctx, httpapi.UserID(ctx), defaultOneTimeAccessTokenDuration)
}
func (uc *UserController) createAdminOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessAdminInput) (*httpapi.BodyOutput[map[string]string], error) {
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
return uc.createOneTimeAccessToken(ctx, input.ID, ttl)
}
func (uc *UserController) createOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) {
if userID == "" {
return nil, &common.UserIdNotProvidedError{}
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(ctx, userID, ttl)
if err != nil {
return nil, err
}
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil
}
func (uc *UserController) requestOneTimeAccessEmailAsUnauthenticatedUserHandler(ctx context.Context, input *oneTimeAccessEmailInput) (*emptyOutputWithCookie, error) {
dbConfig, err := uc.appConfigService.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, dbConfig, input.Body.Email, input.Body.RedirectPath)
if err != nil {
return nil, err
}
return &emptyOutputWithCookie{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil
}
func (uc *UserController) requestOneTimeAccessEmailAsAdminHandler(ctx context.Context, input *oneTimeAccessEmailAdminInput) (*httpapi.EmptyOutput, error) {
dbConfig, err := uc.appConfigService.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
if err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(ctx, dbConfig, input.ID, ttl); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessExchangeInput) (*userCookieOutput, error) {
dbConfig, err := uc.appConfigService.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
if len(input.Token) != 6 && len(input.Token) != 16 {
return nil, &common.TokenInvalidOrExpiredError{}
}
deviceToken := ""
if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil {
deviceToken = requestCookie.Value
}
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(ctx, dbConfig, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
return nil, err
}
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
}
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
return &userCookieOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
}
func (uc *UserController) updateUserGroups(ctx context.Context, input *userGroupsInput) (*httpapi.BodyOutput[dto.UserDto], error) {
user, err := uc.userService.UpdateUserGroups(ctx, input.ID, input.Body.UserGroupIds)
if err != nil {
@@ -578,10 +424,6 @@ func (uc *UserController) verifyEmailHandler(ctx context.Context, input *emailVe
return &httpapi.EmptyOutput{}, nil
}
type emptyOutputWithCookie struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
}
func mapUser(user model.User) (*httpapi.BodyOutput[dto.UserDto], error) {
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {

View File

@@ -62,6 +62,10 @@ type OidcClientCreateDto struct {
ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"`
}
type OidcClientSecretDto struct {
Secret string `json:"secret" required:"false" minLength:"16" pattern:"^[\\x20-\\x7E]+$" patternDescription:"printable ASCII characters" binding:"omitempty,min=16,printascii"`
}
type OidcClientCredentialsDto struct {
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
}

View File

@@ -25,6 +25,7 @@ type UserDto struct {
}
type UserCreateDto struct {
ID string `json:"id" required:"false" format:"uuid"`
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"`

View File

@@ -23,6 +23,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
},
wantErr: "",
},
{
name: "with custom id",
input: UserCreateDto{
ID: "e8b81981-1c92-46e3-bfbf-07096560e6bb",
Username: "testuser",
Email: new("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "",
},
{
name: "missing username",
input: UserCreateDto{

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
package onetimeaccess
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
const defaultTokenDuration = 15 * time.Minute
type tokenCreateInput struct {
ID string `path:"id"`
Body tokenCreateDto
}
type ownTokenCreateInput struct {
Body tokenCreateDto
}
type emailAsUnauthenticatedUserInput struct {
Body emailAsUnauthenticatedUserDto
}
type emailAsAdminInput struct {
ID string `path:"id"`
Body emailAsAdminDto
}
type tokenExchangeInput struct {
Token string `path:"token"`
}
type cookieOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
}
type userOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
Body dto.UserDto
}
type handler struct {
service *Service
appConfig AppConfigResolver
}
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) createOwnToken(ctx context.Context, _ *ownTokenCreateInput) (*httpapi.BodyOutput[map[string]string], error) {
return h.createToken(ctx, httpapi.UserID(ctx), defaultTokenDuration)
}
func (h *handler) createTokenForUser(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[map[string]string], error) {
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultTokenDuration
}
return h.createToken(ctx, input.ID, ttl)
}
func (h *handler) createToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) {
if userID == "" {
return nil, &common.UserIdNotProvidedError{}
}
token, err := h.service.CreateToken(ctx, userID, ttl)
if err != nil {
return nil, err
}
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil
}
func (h *handler) requestEmailAsUnauthenticatedUser(ctx context.Context, input *emailAsUnauthenticatedUserInput) (*cookieOutput, error) {
dbConfig, err := h.appConfig.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
deviceToken, err := h.service.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, dbConfig, input.Body.Email, input.Body.RedirectPath)
if err != nil {
return nil, err
}
return &cookieOutput{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil
}
func (h *handler) requestEmailAsAdmin(ctx context.Context, input *emailAsAdminInput) (*httpapi.EmptyOutput, error) {
dbConfig, err := h.appConfig.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultTokenDuration
}
if err := h.service.RequestOneTimeAccessEmailAsAdmin(ctx, dbConfig, input.ID, ttl); err != nil {
return nil, err
}
return &httpapi.EmptyOutput{}, nil
}
func (h *handler) exchangeToken(ctx context.Context, input *tokenExchangeInput) (*userOutput, error) {
dbConfig, err := h.appConfig.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
if len(input.Token) != 6 && len(input.Token) != 16 {
return nil, &common.TokenInvalidOrExpiredError{}
}
deviceToken := ""
if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil {
deviceToken = requestCookie.Value
}
user, token, err := h.service.ExchangeToken(ctx, dbConfig, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
return nil, err
}
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
}
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
}

View File

@@ -0,0 +1,126 @@
package onetimeaccess
import (
"context"
"fmt"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
// EmailData is the data rendered in the one-time access email
type EmailData struct {
Code string
LoginLink string
LoginLinkWithCode string
ExpirationString string
}
// EmailSender sends the one-time access email
type EmailSender interface {
SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data EmailData) error
}
type TokenService interface {
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
}
type AuditLogger interface {
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
}
type UserProvider interface {
GetUser(ctx context.Context, userID string) (model.User, error)
}
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
type AppConfigResolver interface {
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
}
type Dependencies struct {
DB *gorm.DB
Actors *local.Host
Signer TokenService
AuditLog AuditLogger
UserProvider UserProvider
EmailSender EmailSender
AppConfig AppConfigResolver
}
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) (*Module, error) {
// Register the actor that manages a one-time access token
// Each token is its own actor, whose actor ID is the token's value
err := deps.Actors.RegisterActor(TokenActorType, NewTokenActor)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", TokenActorType, err)
}
service := newService(deps, deps.Actors.Service())
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
}, nil
}
// RegisterRoutes mounts the one-time access token endpoints
// auth guards the admin routes and ownAuth the current user's own token, while the rate limiters throttle the public exchange and email endpoints
func (m *Module) RegisterRoutes(api huma.API, auth, ownAuth func(*huma.Operation), exchangeRateLimit, emailRateLimit func(huma.Context, func(huma.Context))) {
httpapi.Register(api, huma.Operation{
OperationID: "create-own-one-time-access-token",
Method: http.MethodPost,
Path: "/api/users/me/one-time-access-token",
Summary: "Create one-time access token for current user",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, m.handler.createOwnToken, ownAuth)
httpapi.Register(api, huma.Operation{
OperationID: "create-user-one-time-access-token",
Method: http.MethodPost,
Path: "/api/users/{id}/one-time-access-token",
Summary: "Create one-time access token for user",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
}, m.handler.createTokenForUser, auth)
httpapi.Register(api, huma.Operation{
OperationID: "request-user-one-time-access-email",
Method: http.MethodPost,
Path: "/api/users/{id}/one-time-access-email",
Summary: "Request one-time access email for user",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, m.handler.requestEmailAsAdmin, auth)
httpapi.Register(api, huma.Operation{
OperationID: "exchange-one-time-access-token",
Method: http.MethodPost,
Path: "/api/one-time-access-token/{token}",
Summary: "Exchange one-time access token",
Tags: []string{"Users"},
}, m.handler.exchangeToken, httpapi.WithMiddleware(exchangeRateLimit))
httpapi.Register(api, huma.Operation{
OperationID: "request-one-time-access-email",
Method: http.MethodPost,
Path: "/api/one-time-access-email",
Summary: "Request one-time access email",
Tags: []string{"Users"},
DefaultStatus: http.StatusNoContent,
}, m.handler.requestEmailAsUnauthenticatedUser, httpapi.WithMiddleware(emailRateLimit))
}

View File

@@ -0,0 +1,283 @@
package onetimeaccess
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
"github.com/italypaleale/francis/actor"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
// authenticationMethodOneTimePassword identifies one-time password/code authentication
// It must match the value emitted by the JWT service in the access token's "amr" claim
const authenticationMethodOneTimePassword = "otp"
// TokenStore is the minimal interface needed to persist a one-time access token in the actor state store.
// It's satisfied by both *actor.Service (used by the running application) and *local.Host (used by CLI commands, which don't run the full actor host).
type TokenStore interface {
SetState(ctx context.Context, actorType string, actorID string, state any, opts *actor.SetStateOpts) error
}
type Service struct {
db *gorm.DB
actorService *actor.Service
userProvider UserProvider
signer TokenService
auditLog AuditLogger
emailSender EmailSender
}
func newService(deps Dependencies, actorService *actor.Service) *Service {
return &Service{
db: deps.DB,
actorService: actorService,
userProvider: deps.UserProvider,
signer: deps.Signer,
auditLog: deps.AuditLog,
emailSender: deps.EmailSender,
}
}
func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
return err
}
func (s *Service) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
// Load the user to ensure it exists and has an email address
user, err := s.userProvider.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
if err != nil {
return nil, err
}
go func() {
// This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
// We still want to have a context derived from the request's to carry over tracing info
innerCtx := context.WithoutCancel(ctx)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
innerErr := s.emailSender.SendOneTimeAccessEmail(innerCtx, dbConfig, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, EmailData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if innerErr != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", innerErr), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
// Load the user to ensure it exists
_, err = s.userProvider.GetUser(ctx, userID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
token, _, err = StoreToken(ctx, s.actorService, userID, ttl, false)
if err != nil {
return "", err
}
return token, nil
}
func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
// It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite.
res, err := s.actorService.Invoke(ctx, TokenActorType, token, tokenMethodConsume, tokenConsumeRequest{
DeviceToken: deviceToken,
})
if err != nil {
return model.User{}, "", fmt.Errorf("error invoking one-time access token actor: %w", err)
}
var consumeRes tokenConsumeResponse
err = res.Decode(&consumeRes)
if err != nil {
return model.User{}, "", fmt.Errorf("error decoding one-time access token actor response: %w", err)
}
switch consumeRes.Status {
case tokenConsumeNotFound:
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
case tokenConsumeDeviceMismatch:
return model.User{}, "", &common.DeviceCodeInvalid{}
case tokenConsumeOK:
// All good, continue below
default:
return model.User{}, "", fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
}
// The token has now been consumed. From this point on, if we hit an error we compensate by restoring the token (this is best-effort).
user, accessToken, err := s.completeTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent)
if err != nil {
s.restoreToken(ctx, token, consumeRes.State)
return model.User{}, "", err
}
return user, accessToken, nil
}
// completeTokenExchange performs the work that follows consuming a token: loading the user, validating it, and issuing an access token.
func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state TokenState, ipAddress, userAgent string) (model.User, string, error) {
var user model.User
err := s.db.
WithContext(ctx).
Where("id = ?", state.UserID).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} else if err != nil {
return model.User{}, "", err
}
if user.Disabled {
return model.User{}, "", &common.UserDisabledError{}
}
accessToken, err := s.signer.GenerateAccessToken(
user,
authenticationMethodOneTimePassword,
dbConfig.SessionDuration.AsDurationMinutes(),
)
if err != nil {
return model.User{}, "", err
}
s.auditLog.Create(
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
ipAddress, userAgent,
user.ID,
model.AuditLogData{},
s.db,
)
return user, accessToken, nil
}
// restoreToken restores a token that was consumed but whose exchange could not be completed.
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that the token was consumed unnecessarily.
func (s *Service) restoreToken(parentCtx context.Context, token string, state TokenState) {
// Use a context that is not canceled when the original request ends
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
defer cancel()
_, err := s.actorService.Invoke(ctx, TokenActorType, token, TokenMethodRestore, state)
if err != nil {
slog.ErrorContext(ctx, "Failed to restore one-time access token after a failed exchange", slog.Any("error", err))
}
}
// StoreToken generates a new one-time access token and persists it in the actor state store, with a TTL matching its lifetime.
// It returns the token value and, when requested, the associated device token.
func StoreToken(ctx context.Context, store TokenStore, userID string, ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
token, deviceToken, err = generateToken(ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
now := time.Now().Round(time.Second)
state := TokenState{
UserID: userID,
DeviceToken: deviceToken,
ExpiresAt: now.Add(ttl),
}
err = store.SetState(ctx, TokenActorType, token, state, &actor.SetStateOpts{TTL: ttl})
if err != nil {
return "", nil, fmt.Errorf("error saving one-time access token state: %w", err)
}
return token, deviceToken, nil
}
// generateToken generates the random token value (and optional device token) for a one-time access token.
func generateToken(ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err = utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return "", nil, err
}
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", nil, err
}
deviceToken = &dt
}
return token, deviceToken, nil
}

View File

@@ -0,0 +1,172 @@
package onetimeaccess
import (
"context"
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeSigner struct{}
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
return "access-token", nil
}
type fakeAuditLogger struct {
events []model.AuditLogEvent
}
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
f.events = append(f.events, event)
return model.AuditLog{}, true
}
type fakeUserProvider struct {
db *gorm.DB
}
func (f fakeUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
var user model.User
err := f.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
return user, err
}
type fakeEmailSender struct{}
func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _ email.Address, _ EmailData) error {
return nil
}
// newServiceForTest sets up a Service backed by an in-memory test actor host, and returns it together with the host and the audit logger it records into
func newServiceForTest(t *testing.T, db *gorm.DB) (*Service, *local.Host, *fakeAuditLogger) {
t.Helper()
auditLog := &fakeAuditLogger{}
var svc *Service
host := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
err := h.RegisterActor(TokenActorType, NewTokenActor)
require.NoError(t, err)
svc = newService(Dependencies{
DB: db,
Signer: fakeSigner{},
AuditLog: auditLog,
UserProvider: fakeUserProvider{db: db},
EmailSender: fakeEmailSender{},
}, h.Service())
})
require.NotNil(t, svc)
return svc, host, auditLog
}
func TestExchangeTokenSuccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, auditLog := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "enabled-user"},
Username: "enabled-user",
}
require.NoError(t, db.Create(&user).Error)
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
require.NoError(t, err)
require.Equal(t, user.ID, exchangedUser.ID)
require.NotEmpty(t, accessToken)
// The token must have been consumed
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.ErrorIs(t, err, actor.ErrStateNotFound)
// A sign-in audit log must have been created
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
}
func TestExchangeTokenInvalidToken(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, _, _ := newServiceForTest(t, db)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
}
func TestExchangeTokenDeviceMismatch(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, _ := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "device-user"},
Username: "device-user",
}
require.NoError(t, db.Create(&user).Error)
// Store a token that requires a device token
token, deviceToken, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, true)
require.NoError(t, err)
require.NotNil(t, deviceToken)
dbConfig := appconfig.NewTestConfig(nil)
_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
var deviceErr *common.DeviceCodeInvalid
require.ErrorAs(t, err, &deviceErr)
// The token must not have been consumed on a device-token mismatch
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
}
func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc, host, auditLog := newServiceForTest(t, db)
user := model.User{
Base: model.Base{ID: "disabled-user"},
Username: "disabled-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
// Store a one-time access token for the disabled user in the actor state store
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)
// The token must have been restored (not consumed), since the exchange failed because the user is disabled
var state TokenState
err = host.GetState(t.Context(), TokenActorType, token, &state)
require.NoError(t, err)
require.Equal(t, user.ID, state.UserID)
require.Empty(t, auditLog.events)
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/google/uuid"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -31,6 +33,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -41,6 +44,7 @@ import (
type TestService struct {
db *gorm.DB
actors *local.Host
jwtService *JwtService
appConfigService *appconfig.AppConfigService
ldapService *LdapService
@@ -56,9 +60,10 @@ const (
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
)
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
actors: actors,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
@@ -136,29 +141,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
@@ -220,6 +202,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,12 +267,58 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
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(&accessToken).Error; err != nil {
if err := tx.Create(&reauthenticationToken).Error; err != nil {
return err
}
@@ -448,53 +479,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
signupTokens := []usersignup.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
UserGroups: []model.UserGroup{
userGroups[0],
},
},
{
Base: model.Base{
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
@@ -541,6 +525,116 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
// One-time access tokens and signup tokens live in the actor state store, so they're seeded separately from the DB transaction above.
err = s.seedOneTimeAccessTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed one-time access tokens: %w", err)
}
err = s.seedSignupTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed signup tokens: %w", err)
}
return nil
}
// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
func (s *TestService) seedSignupTokens(ctx context.Context) error {
now := time.Now().Round(time.Second)
tokens := map[string]usersignup.SignupTokenState{
"VALID1234567890A": {
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 0,
UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
CreatedAt: now,
},
"PARTIAL567890ABC": {
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
ExpiresAt: now.Add(7 * 24 * time.Hour),
UsageLimit: 5,
UsageCount: 2,
CreatedAt: now,
},
"FULLYUSED567890C": {
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
CreatedAt: now,
},
}
// The actor state store isn't wiped by ResetDatabase, so remove any signup token left over from a previous test first
err := s.deleteAllSignupTokens(ctx)
if err != nil {
return err
}
// Each signup token is its own actor, whose actor ID is the token's value
for token, state := range tokens {
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, token, usersignup.SignupTokenMethodCreate, state)
if err != nil {
return fmt.Errorf("failed to seed signup token %q: %w", token, err)
}
}
return nil
}
// deleteAllSignupTokens removes every signup token currently stored in the actor state store
func (s *TestService) deleteAllSignupTokens(ctx context.Context) error {
var after string
for {
res, err := s.actors.Service().ListStates(ctx, usersignup.SignupTokenActorType, &actor.ListStatesOpts{After: after})
if err != nil {
return fmt.Errorf("failed to list signup tokens: %w", err)
}
for _, st := range res.States {
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, st.ActorID, usersignup.SignupTokenMethodDelete, nil)
if err != nil {
return fmt.Errorf("failed to delete signup token %q: %w", st.ActorID, err)
}
}
// An empty cursor means we've just read the last page
after = res.AfterID()
if after == "" {
return nil
}
}
}
// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
tokens := []struct {
token string
ttl time.Duration
}{
{token: "HPe6k6uiDRRVuAQV", ttl: time.Hour},
{token: "one-time-token", ttl: time.Hour},
}
for _, t := range tokens {
state := onetimeaccess.TokenState{
UserID: e2eRefreshTokenUserID,
ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
}
// Seed through the actor's "restore" method (which sets the state) rather than writing the
// state directly: if an actor for this token is still active from a previous test (for
// example, one whose token was already consumed), invoking it refreshes its in-memory cache
// too, whereas a direct state write would leave that cache stale.
_, err := s.actors.Service().Invoke(ctx, onetimeaccess.TokenActorType, t.token, onetimeaccess.TokenMethodRestore, state)
if err != nil {
return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
}
}
return nil
}

View File

@@ -268,7 +268,7 @@ func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
return nil
}
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (string, error) {
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretDto) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -283,9 +283,12 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (
return "", err
}
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
clientSecret := input.Secret
if clientSecret == "" {
clientSecret, err = utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
}
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
@@ -589,23 +592,11 @@ 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

View File

@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/unicode/norm"
@@ -15,6 +17,7 @@ import (
"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"
)
@@ -501,6 +504,29 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
assert.Empty(t, fetched.Description)
}
func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
client := model.OidcClient{Name: "Test Client"}
err = db.Create(&client).Error
require.NoError(t, err)
customSecret := "custom-client-secret-with-a-minimum-length"
input := dto.OidcClientSecretDto{Secret: customSecret}
secret, err := s.CreateClientSecret(t.Context(), client.ID, input)
require.NoError(t, err)
assert.Equal(t, customSecret, secret)
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fetched.Secret), []byte(customSecret)))
}
func TestOidcService_UpdateClient_description(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
@@ -541,3 +567,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
}

View File

@@ -0,0 +1,29 @@
package service
import (
"context"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
// OneTimeAccessEmailSender sends the one-time access email.
// It adapts the email service, which owns the email templates, to the interface the onetimeaccess module depends on.
type OneTimeAccessEmailSender struct {
emailService *EmailService
}
func NewOneTimeAccessEmailSender(emailService *EmailService) *OneTimeAccessEmailSender {
return &OneTimeAccessEmailSender{emailService: emailService}
}
// SendOneTimeAccessEmail implements onetimeaccess.EmailSender
func (s *OneTimeAccessEmailSender) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data onetimeaccess.EmailData) error {
return SendEmail(ctx, s.emailService, dbConfig, to, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: data.Code,
LoginLink: data.LoginLink,
LoginLinkWithCode: data.LoginLinkWithCode,
ExpirationString: data.ExpirationString,
})
}

View File

@@ -1,261 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.getUserInternal(ctx, userID, tx)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
go func() {
// This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
// We still want to have a context derived from the request's to carry over tracing info
innerCtx := context.WithoutCancel(ctx)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, dbConfig, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Load the user to ensure it exists
_, err = s.userService.getUserInternal(ctx, userID, tx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
// Create the one-time access token
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
if err != nil {
return "", err
}
// Commit
err = tx.Commit().Error
if err != nil {
return "", fmt.Errorf("error committing transaction: %w", err)
}
return token, nil
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
if oneTimeAccessToken.User.Disabled {
return model.User{}, "", &common.UserDisabledError{}
}
accessToken, err := s.jwtService.GenerateAccessToken(
oneTimeAccessToken.User,
AuthenticationMethodOneTimePassword,
dbConfig.SessionDuration.AsDurationMinutes(),
)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
ipAddress, userAgent,
oneTimeAccessToken.User.ID, model.AuditLogData{},
tx,
)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
}
return oneTimeAccessToken.User, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}

View File

@@ -1,53 +0,0 @@
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
appConfig := appconfig.NewTestAppConfigService(nil)
instanceID := newInstanceID(t, db)
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig)
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil)
user := model.User{
Base: model.Base{ID: "disabled-user"},
Username: "disabled-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
loginCode := model.OneTimeAccessToken{
Base: model.Base{ID: "disabled-user-login-code"},
Token: "ABCDEF",
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
UserID: user.ID,
}
require.NoError(t, db.Create(&loginCode).Error)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)
var remainingLoginCode model.OneTimeAccessToken
require.NoError(t, db.Where("token = ?", loginCode.Token).First(&remainingLoginCode).Error)
var auditLogCount int64
require.NoError(t, db.Model(&model.AuditLog{}).Where("user_id = ?", user.ID).Count(&auditLogCount).Error)
require.Zero(t, auditLogCount)
}

View File

@@ -285,6 +285,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
Disabled: input.Disabled,
UserGroups: userGroups,
}
if input.ID != "" {
user.ID = input.ID
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
}

View File

@@ -0,0 +1,217 @@
package usersignup
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Signup tokens are stored entirely in the actor state store.
// Each token is its own actor, whose actor ID is the token value itself.
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job and no expiration alarm).
// Listing tokens uses ListStates, which only returns states that haven't expired yet.
// SignupTokenActorType is the actor type for the signup token actor
const SignupTokenActorType = "SignupToken"
// Methods exposed by the signup token actor
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates it and increments its usage count), and only afterwards performing the remaining work.
// On failure, the caller compensates by releasing the token via the "release" method as best-effort.
const (
// SignupTokenMethodCreate stores a new signup token, replacing any existing state
SignupTokenMethodCreate = "create"
// SignupTokenMethodDelete removes a signup token
SignupTokenMethodDelete = "delete"
signupTokenMethodMigrate = "migrate"
signupTokenMethodConsume = "consume"
signupTokenMethodRelease = "release"
)
// signupTokenConsumeStatus is the outcome of a "consume" invocation.
type signupTokenConsumeStatus string
const (
// signupTokenConsumeOK indicates the token was valid and one use has been consumed
signupTokenConsumeOK signupTokenConsumeStatus = "ok"
// signupTokenConsumeNotFound indicates the token doesn't exist (or has expired)
signupTokenConsumeNotFound signupTokenConsumeStatus = "not_found"
// signupTokenConsumeLimitReached indicates the token has no uses left
signupTokenConsumeLimitReached signupTokenConsumeStatus = "limit_reached"
)
// SignupTokenState is the persisted state of a signup token actor.
// The token value itself is the actor's ID, so it isn't repeated here.
type SignupTokenState struct {
ID string
UsageLimit int
UsageCount int
UserGroupIDs []string
CreatedAt time.Time
ExpiresAt time.Time
}
// signupTokenConsumeResponse is the response of the "consume" method
type signupTokenConsumeResponse struct {
Status signupTokenConsumeStatus
// UserGroupIDs is set only when Status is "ok", and contains the groups the new user should join
UserGroupIDs []string
}
// signupTokenActor is the actor that manages a single signup token
type signupTokenActor struct {
log *slog.Logger
client actor.Client[SignupTokenState]
}
// NewSignupTokenActor allocates a new signup token actor
// It satisfies actor.Factory
func NewSignupTokenActor(actorID string, service *actor.Service) actor.Actor {
return &signupTokenActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", SignupTokenActorType),
),
client: actor.NewActorClient[SignupTokenState](SignupTokenActorType, actorID, service),
}
}
// Invoke implements actor.ActorInvoke
func (a *signupTokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
switch method {
case SignupTokenMethodCreate:
return nil, a.create(parentCtx, data, false)
case signupTokenMethodMigrate:
return nil, a.create(parentCtx, data, true)
case signupTokenMethodConsume:
return a.consume(parentCtx)
case signupTokenMethodRelease:
return nil, a.release(parentCtx)
case SignupTokenMethodDelete:
return nil, a.delete(parentCtx)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
// create stores the token's state.
// When onlyIfMissing is true the write is skipped if the actor already has state: this is used by the one-time migration of the pre-actor tokens, so a token that has already been migrated is never reset.
func (a *signupTokenActor) create(parentCtx context.Context, data actor.Envelope, onlyIfMissing bool) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", SignupTokenMethodCreate)
}
var state SignupTokenState
err := data.Decode(&state)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", SignupTokenMethodCreate, err)
}
if onlyIfMissing {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
current, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// An empty ID means there's no state yet
if current.ID != "" {
return nil
}
}
return a.setState(parentCtx, state)
}
// consume atomically validates the token and, if it's still usable, records one more use.
func (a *signupTokenActor) consume(parentCtx context.Context) (signupTokenConsumeResponse, error) {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return signupTokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
}
// An empty ID means there's no state: the token doesn't exist (or its state already expired and was purged)
if state.ID == "" || state.ExpiresAt.Before(time.Now()) {
return signupTokenConsumeResponse{
Status: signupTokenConsumeNotFound,
}, nil
}
if state.UsageCount >= state.UsageLimit {
return signupTokenConsumeResponse{
Status: signupTokenConsumeLimitReached,
}, nil
}
// Consume one use of the token
state.UsageCount++
err = a.setState(parentCtx, state)
if err != nil {
return signupTokenConsumeResponse{}, err
}
return signupTokenConsumeResponse{
Status: signupTokenConsumeOK,
UserGroupIDs: state.UserGroupIDs,
}, nil
}
// release reverts the usage count increment performed while consuming the token, to compensate when the signup could not be completed.
func (a *signupTokenActor) release(parentCtx context.Context) error {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// The token is gone (for example, it expired and was purged) or was never consumed: nothing to compensate
if state.ID == "" || state.UsageCount <= 0 {
return nil
}
state.UsageCount--
return a.setState(parentCtx, state)
}
// delete removes the token.
func (a *signupTokenActor) delete(parentCtx context.Context) error {
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err := a.client.DeleteState(ctx)
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
// Deleting a token that doesn't exist (for example, one that expired in the meanwhile) already reaches the desired end state
return fmt.Errorf("error deleting actor state: %w", err)
}
return nil
}
// setState saves the state with a TTL matching the token's remaining lifetime, so it's purged automatically once the token expires.
// Saving is skipped if the token has already expired, since there would be nothing left to store.
func (a *signupTokenActor) setState(parentCtx context.Context, state SignupTokenState) error {
ttl := time.Until(state.ExpiresAt)
if ttl <= 0 {
return nil
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err := a.client.SetState(ctx, state, &actor.SetStateOpts{
TTL: ttl,
})
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,173 @@
package usersignup
import (
"testing"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/require"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// newSignupTokenActorService starts a test actor host with the signup token actor registered and returns its service
func newSignupTokenActorService(t *testing.T) *actor.Service {
t.Helper()
var svc *actor.Service
testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
err := h.RegisterActor(SignupTokenActorType, NewSignupTokenActor)
require.NoError(t, err)
svc = h.Service()
})
require.NotNil(t, svc)
return svc
}
func createSignupTokenForTest(t *testing.T, svc *actor.Service, token string, state SignupTokenState) {
t.Helper()
_, err := svc.Invoke(t.Context(), SignupTokenActorType, token, SignupTokenMethodCreate, state)
require.NoError(t, err)
}
func consumeSignupTokenForTest(t *testing.T, svc *actor.Service, token string) signupTokenConsumeResponse {
t.Helper()
res, err := svc.Invoke(t.Context(), SignupTokenActorType, token, signupTokenMethodConsume, nil)
require.NoError(t, err)
var out signupTokenConsumeResponse
err = res.Decode(&out)
require.NoError(t, err)
return out
}
// listSignupTokenIDsForTest returns the actor IDs (that is, the token values) of every stored signup token
func listSignupTokenIDsForTest(t *testing.T, svc *actor.Service) []string {
t.Helper()
res, err := svc.ListStates(t.Context(), SignupTokenActorType, nil)
require.NoError(t, err)
ids := make([]string, len(res.States))
for i, st := range res.States {
ids[i] = st.ActorID
}
return ids
}
func TestSignupTokenActorConsume(t *testing.T) {
svc := newSignupTokenActorService(t)
createSignupTokenForTest(t, svc, "token-1", SignupTokenState{
ID: "id-1",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 1,
UserGroupIDs: []string{"group-a", "group-b"},
CreatedAt: time.Now(),
})
// First consume succeeds and returns the token's user groups
res := consumeSignupTokenForTest(t, svc, "token-1")
require.Equal(t, signupTokenConsumeOK, res.Status)
require.Equal(t, []string{"group-a", "group-b"}, res.UserGroupIDs)
// Second consume fails: the usage limit (1) has been reached
res = consumeSignupTokenForTest(t, svc, "token-1")
require.Equal(t, signupTokenConsumeLimitReached, res.Status)
}
func TestSignupTokenActorConsumeNotFound(t *testing.T) {
svc := newSignupTokenActorService(t)
res := consumeSignupTokenForTest(t, svc, "does-not-exist")
require.Equal(t, signupTokenConsumeNotFound, res.Status)
}
// TestSignupTokenActorCreateExpired verifies that a token that has already expired is never stored, since its state TTL would be in the past
func TestSignupTokenActorCreateExpired(t *testing.T) {
svc := newSignupTokenActorService(t)
createSignupTokenForTest(t, svc, "token-expired", SignupTokenState{
ID: "id-expired",
ExpiresAt: time.Now().Add(-time.Minute),
UsageLimit: 1,
CreatedAt: time.Now().Add(-time.Hour),
})
require.Empty(t, listSignupTokenIDsForTest(t, svc))
require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-expired").Status)
}
func TestSignupTokenActorRelease(t *testing.T) {
svc := newSignupTokenActorService(t)
createSignupTokenForTest(t, svc, "token-2", SignupTokenState{
ID: "id-2",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 2,
CreatedAt: time.Now(),
})
// Consume both uses
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-2").Status)
// Release one use (compensation)
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-2", signupTokenMethodRelease, nil)
require.NoError(t, err)
// Consuming succeeds again now that a use was released
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
}
func TestSignupTokenActorDelete(t *testing.T) {
svc := newSignupTokenActorService(t)
createSignupTokenForTest(t, svc, "token-3", SignupTokenState{
ID: "id-3",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 1,
CreatedAt: time.Now(),
})
require.Equal(t, []string{"token-3"}, listSignupTokenIDsForTest(t, svc))
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil)
require.NoError(t, err)
require.Empty(t, listSignupTokenIDsForTest(t, svc))
// The token can no longer be consumed
require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-3").Status)
// Deleting a token that no longer exists is a no-op
_, err = svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil)
require.NoError(t, err)
}
// TestSignupTokenActorMigrateDoesNotOverwrite verifies that the one-time migration never resets a token that was already migrated and used since
func TestSignupTokenActorMigrateDoesNotOverwrite(t *testing.T) {
svc := newSignupTokenActorService(t)
state := SignupTokenState{
ID: "id-4",
ExpiresAt: time.Now().Add(time.Hour),
UsageLimit: 2,
CreatedAt: time.Now(),
}
createSignupTokenForTest(t, svc, "token-4", state)
// Use the token once
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status)
// Re-running the migration must not reset the usage count
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-4", signupTokenMethodMigrate, state)
require.NoError(t, err)
// Only one use is left, so a single consume succeeds and the next one doesn't
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status)
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-4").Status)
}

View File

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

View File

@@ -0,0 +1,105 @@
package usersignup
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// This file holds the one-time migration of the pre-actor signup tokens.
// The "actor tokens" migration freezes the signup_tokens table (and its user-group associations) into a JSON document stored in the "kv" table under the "signup_tokens_migrated" key.
// It's loaded here to create the per-token actors on first startup.
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
// All timestamps are expressed as Unix seconds.
type migratedSignupToken struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
UserGroupIDs []string `json:"userGroupIds"`
CreatedAt int64 `json:"createdAt"`
}
// migrateSignupTokens creates an actor for every signup token frozen into the kv table by the migration.
// It requires the actor state store to be available, so it must run after the actor host is ready.
// It is idempotent: tokens that have already been migrated are left untouched, so a token that has been used since it was migrated is never reset.
func (s *Service) migrateSignupTokens(ctx context.Context) error {
migrated, err := loadMigratedSignupTokens(ctx, s.db)
if err != nil {
return err
}
if len(migrated) == 0 {
return nil
}
var count int
for _, m := range migrated {
// Skip tokens that have already expired, since there would be nothing left to store
expiresAt := time.Unix(m.ExpiresAt, 0)
if !expiresAt.After(time.Now()) {
continue
}
state := SignupTokenState{
ID: m.ID,
ExpiresAt: expiresAt,
UsageLimit: m.UsageLimit,
UsageCount: m.UsageCount,
UserGroupIDs: m.UserGroupIDs,
CreatedAt: time.Unix(m.CreatedAt, 0),
}
// The token's value is the actor's ID
// The "migrate" method only writes the state if the actor doesn't have one already
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, m.Token, signupTokenMethodMigrate, state)
if err != nil {
return fmt.Errorf("error migrating signup token '%s': %w", m.ID, err)
}
count++
}
slog.InfoContext(ctx, "Migrated signup tokens to actors", slog.Int("count", count))
return nil
}
// loadMigratedSignupTokens reads the signup tokens frozen into the kv table by the migration
// It returns nil if there's nothing to migrate
func loadMigratedSignupTokens(ctx context.Context, db *gorm.DB) ([]migratedSignupToken, error) {
row := model.KV{
Key: signupTokensMigratedKey,
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := db.WithContext(ctx).First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// There are no migrated signup tokens in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated signup tokens from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated signup tokens, nothing to do
return nil, nil
}
var migrated []migratedSignupToken
err = json.Unmarshal([]byte(*row.Value), &migrated)
if err != nil {
return nil, fmt.Errorf("error parsing migrated signup tokens: %w", err)
}
return migrated, nil
}

View File

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

View File

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

View File

@@ -2,10 +2,12 @@ package usersignup
import (
"context"
"fmt"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
@@ -32,7 +34,8 @@ type AppConfigResolver interface {
}
type Dependencies struct {
DB *gorm.DB
DB *gorm.DB
Actors *local.Host
Signer TokenService
AuditLog AuditLogger
@@ -45,12 +48,32 @@ type Module struct {
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps)
func New(deps Dependencies) (*Module, error) {
// Register the actor that manages a signup token
// Each token is its own actor, whose actor ID is the token's value
err := deps.Actors.RegisterActor(SignupTokenActorType, NewSignupTokenActor)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", SignupTokenActorType, err)
}
service := newService(deps, deps.Actors.Service())
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
}, nil
}
// RunSignupTokenMigration performs the one-time migration of the pre-actor signup tokens, then blocks until the context is canceled.
// It's meant to be started as a background service gated on the actor host being ready, since the migration needs the actor state store.
// Note that it must not return before the context is canceled, as the service runner stops the application as soon as any of its services returns.
func (m *Module) RunSignupTokenMigration(ctx context.Context) error {
err := m.service.migrateSignupTokens(ctx)
if err != nil {
return fmt.Errorf("failed to migrate signup tokens: %w", err)
}
<-ctx.Done()
return ctx.Err()
}
// RegisterRoutes mounts the signup and signup-token management endpoints

View File

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

View File

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

View File

@@ -248,8 +248,10 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
}
session := gowebauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification),
CredParams: storedSession.CredentialParams,
}
var user *model.User
@@ -450,8 +452,10 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
}
session := gowebauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification),
CredParams: storedSession.CredentialParams,
}
// Validate the credential assertion

View File

@@ -0,0 +1 @@
-- No-op on PostgreSQL

View File

@@ -0,0 +1 @@
-- No-op on PostgreSQL because its OAuth storage types already match the export format

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -19,7 +19,7 @@
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-email": "6.7.0",
"react-email": "6.9.0",
"tsx": "^4.22.2"
}
}

View File

@@ -3,13 +3,13 @@
"my_account": "Minha Conta",
"logout": "Sair",
"confirm": "Confirmar",
"docs": "Documentos",
"docs": "Documentação",
"key": "Chave",
"value": "Valor",
"remove_custom_claim": "Tirar reivindicação personalizada",
"remove_custom_claim": "Remover reivindicação personalizada",
"add_custom_claim": "Adicionar reivindicação personalizada",
"add_another": "Adicionar outro",
"select_a_date": "Selecione a data",
"select_a_date": "Selecione uma data",
"select_file": "Selecionar Arquivo",
"profile_picture": "Foto de Perfil",
"profile_picture_is_managed_by_ldap_server": "A foto de perfil é gerenciada pelo servidor LDAP e não pode ser alterada aqui.",
@@ -28,7 +28,7 @@
"login_background": "Histórico de login",
"logo": "Logotipo",
"login_code": "Código de Login",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crie um código de login de uso único para que o usuário possa entrar sem precisar de uma chave de acesso.",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crie um código de login para o usuário entrar uma vez sem chave de acesso.",
"one_hour": "1 hora",
"twelve_hours": "12 horas",
"one_day": "1 dia",
@@ -46,9 +46,9 @@
"authenticator_does_not_support_any_of_the_requested_algorithms": "O autenticador não suporta nenhum dos algoritmos solicitados",
"webauthn_error_invalid_rp_id": "A identificação da parte confiável configurada não está válida.",
"webauthn_error_invalid_domain": "O domínio configurado não está certo.",
"contact_administrator_to_fix": "Fala com o administrador pra resolver esse problema.",
"webauthn_operation_not_allowed_or_timed_out": "A operação não foi permitida ou expirou.",
"webauthn_not_supported_by_browser": "As chaves de acesso não são suportadas por este navegador. Por favor, use um método alternativo de login.",
"contact_administrator_to_fix": "Fala com o administrador para resolver esse problema.",
"webauthn_operation_not_allowed_or_timed_out": "A operação não foi permitida ou expirou",
"webauthn_not_supported_by_browser": "Chaves de acesso não são suportadas por este navegador. Por favor, use um método de login alternativo.",
"critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.",
"sign_in_to": "Entrar em {name}",
"account_selection_signin_confirmation": "Queres usar a seguinte conta para continuar <b>{name}</b>?",

5429
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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}

View File

@@ -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()}

832
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,6 @@
"prettier": "^3.8.3"
},
"dependencies": {
"adm-zip": "^0.5.17"
"adm-zip": "^0.6.0"
}
}

View File

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

View File

@@ -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');