Compare commits

..

21 Commits

Author SHA1 Message Date
ItalyPaleAle
669834d2d3 More WIP 2026-07-14 08:59:08 -07:00
ItalyPaleAle
2b11164532 More WIP 2026-07-14 08:57:47 -07:00
ItalyPaleAle
1d2f991be5 More WIP 2026-07-13 07:13:00 -07:00
ItalyPaleAle
eac837fc22 Merge branch 'main' of https://github.com/pocket-id/pocket-id into actors/config 2026-07-13 06:22:24 -07:00
Elias Schneider
525946e94e release: 2.11.0 2026-07-13 11:16:16 +02:00
Elias Schneider
bb03660bd7 ci/cd: remove low demand issue closer 2026-07-13 11:08:32 +02:00
Elias Schneider
9714296efb fix: block link-local addresses in SSRF protection 2026-07-13 11:05:24 +02:00
Elias Schneider
a3f27ec2ec fix: ignore tab URL hashes in navigation history 2026-07-13 10:52:01 +02:00
Elias Schneider
316cf47ceb fix: INTERNAL_APP_URL not reflected in UI URLs 2026-07-13 10:51:24 +02:00
dependabot[bot]
ef9ed8de32 chore(deps): Bump the "all-dependencies" group with 3 updates across multiple ecosystems (#1578)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-13 09:58:41 +02:00
James18232
217bf75b7c fix: login code mobile ux (#1584)
Co-authored-by: james <james@goldfish.net>
2026-07-13 09:52:57 +02:00
Elias Schneider
77398a558d fix: sort signup tokens by creation date explicitly 2026-07-13 09:50:25 +02:00
Elias Schneider
187cd8ddcd feat: add support for CIDR and IP address lists in TRUST_PROXY 2026-07-13 09:32:05 +02:00
Elias Schneider
d9ead47d19 fix: allow insecure callback URLs by default until next major release 2026-07-13 09:21:53 +02:00
Elias Schneider
7348bdac50 chore(translations): update translations via Crowdin (#1589) 2026-07-12 12:48:39 -05:00
ItalyPaleAle
0f6ff76165 Add Replace and Update funtions to model 2026-07-11 16:00:16 -07:00
ItalyPaleAle
e4d0cbefbe WIP 2026-07-11 12:19:04 -07:00
Elias Schneider
69a6e22c7c chore(translations): update translations via Crowdin (#1577) 2026-07-11 16:16:47 +02:00
Alessandro (Ale) Segala
cfda5f693b refactor: manage instance ID in the KV table (#1579) 2026-07-11 16:16:05 +02:00
Marco Scabbiolo
ff8e34cccf fix: postgres migration 20250822 remove public schema references (#1582)
Co-authored-by: Marco <marco.scabbiolo@redb.ee>
2026-07-11 16:11:07 +02:00
Elias Schneider
ff5e565eeb docs: update README 2026-07-11 14:54:22 +02:00
97 changed files with 2535 additions and 1635 deletions

View File

@@ -15,4 +15,4 @@ ENCRYPTION_KEY=
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000
PGID=1000
PGID=1000

View File

@@ -33,9 +33,9 @@ jobs:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@v9.2.1
uses: golangci/golangci-lint-action@v9.3.0
with:
version: v2.11.4
args: --build-tags=exclude_frontend
version: v2.12.2
args: --config=.golangci.yml
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -51,20 +51,20 @@ jobs:
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
uses: goreleaser/goreleaser-action@v7.2.3
with:
distribution: goreleaser-pro
version: "~> v2"

View File

@@ -1,24 +0,0 @@
name: Close Stale Feature Requests
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch:
inputs:
dry-run:
description: "Log matching issues without closing them"
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
jobs:
close-stale-feature-requests:
runs-on: ubuntu-latest
steps:
- uses: pocket-id/low-demand-closer@v1
with:
dry-run: ${{ inputs['dry-run'] || false }}

View File

@@ -46,20 +46,20 @@ jobs:
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
uses: goreleaser/goreleaser-action@v7.2.3
with:
distribution: goreleaser-pro
version: "~> v2"

View File

@@ -31,7 +31,7 @@ jobs:
working-directory: backend
run: |
set -e -o pipefail
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
go test -tags=exclude_frontend,unit -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v7
if: always()
with:

View File

@@ -1 +1 @@
2.10.0
2.11.0

View File

@@ -1,4 +1,4 @@
{
"go.buildTags": "e2etest",
"go.buildTags": "e2etest unit",
"prettier.documentSelectors": ["**/*.svelte"]
}

View File

@@ -12,10 +12,10 @@ the binary for production). This file lists what isn't obvious from reading the
## Build / test / lint
```sh
# backend/ — the exclude_frontend tag is mandatory locally; CI uses it too
go test -tags=exclude_frontend ./... # unit/integration tests
go test -tags=exclude_frontend -run TestName ./internal/... # a single test
golangci-lint run --build-tags=exclude_frontend # lint (config: backend/.golangci.yml)
# backend/ — the exclude_frontend and unit tags are mandatory locally; CI uses them too
go test -tags=exclude_frontend,unit ./... # unit/integration tests
go test -tags=exclude_frontend,unit -run TestName ./internal/... # a single test
golangci-lint run # lint (config: backend/.golangci.yml - includes build tags)
# frontend/ (or root)
pnpm check # svelte-check — the ONLY frontend type gate (no unit tests exist)
@@ -32,9 +32,8 @@ cd ../.. && pnpm test # = playwright test in tests/
## Critical gotchas
- **`exclude_frontend` build tag.** `backend/frontend/` embeds `dist/` via `go:embed`. Without
a built frontend present, plain `go run`/`go test`/`golangci-lint` fail to compile. Always pass
`-tags exclude_frontend` for backend dev/test/lint. Production builds omit it to embed the SPA.
- **`exclude_frontend` and `unit` build tags.** Without them plain `go run`/`go test`/`golangci-lint` fail
to run. Always pass `-tags exclude_frontend,unit` for backend dev/test/lint.
- **Never edit generated files:** `frontend/src/lib/paraglide/**` (Paraglide i18n output) and
`backend/frontend/dist/**`. For i18n, only edit `frontend/messages/en.json`; other locales come
from Crowdin.

View File

@@ -1,3 +1,31 @@
## v2.11.0
### Bug Fixes
- postgres migration 20250822 remove public schema references ([#1582](https://github.com/pocket-id/pocket-id/pull/1582) by @MarcoScabbiolo)
- allow insecure callback URLs by default until next major release ([d9ead47](https://github.com/pocket-id/pocket-id/commit/d9ead47d19451b256336b7d0eb46606a236928f3) by @stonith404)
- sort signup tokens by creation date explicitly ([77398a5](https://github.com/pocket-id/pocket-id/commit/77398a558df45e1e332316afbb0f0c5e416350ea) by @stonith404)
- login code mobile ux ([#1584](https://github.com/pocket-id/pocket-id/pull/1584) by @James18232)
- `INTERNAL_APP_URL` not reflected in UI URLs ([316cf47](https://github.com/pocket-id/pocket-id/commit/316cf47cebdb7337cef13a533a8105ef4364b6f5) by @stonith404)
- ignore tab URL hashes in navigation history ([a3f27ec](https://github.com/pocket-id/pocket-id/commit/a3f27ec2ec1ce784ca11e5fb41f92a0367715393) by @stonith404)
- block link-local addresses in SSRF protection ([9714296](https://github.com/pocket-id/pocket-id/commit/9714296efb3768e4f29479f48628318565a3bed7) by @stonith404)
### Documentation
- update README ([ff5e565](https://github.com/pocket-id/pocket-id/commit/ff5e565eebbba0dba991cf06ad76249b7af26432) by @stonith404)
### Features
- add support for CIDR and IP address lists in `TRUST_PROXY` ([187cd8d](https://github.com/pocket-id/pocket-id/commit/187cd8ddcd4d6babec73bdf7e81eab2dee1ade04) by @stonith404)
### Other
- manage instance ID in the KV table ([#1579](https://github.com/pocket-id/pocket-id/pull/1579) by @ItalyPaleAle)
- Bump the "all-dependencies" group with 3 updates across multiple ecosystems ([#1578](https://github.com/pocket-id/pocket-id/pull/1578) by @dependabot[bot])
- remove low demand issue closer ([bb03660](https://github.com/pocket-id/pocket-id/commit/bb03660bd7d48dfd0b022936dabd2b2880c9b382) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.10.0...v2.11.0
## v2.10.0
### Bug Fixes

View File

@@ -125,4 +125,4 @@ If you make any changes to the application, you have to rebuild the test environ
In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix.
To run the tests, simply run `go test ./...` from the root of the `backend` folder.
To run the tests, simply run `go test -tags=exclude_frontend,unit ./...` from the root of the `backend` folder.

View File

@@ -1,15 +1,19 @@
# <div align="center"><img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"/> </br>Pocket ID</div>
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
Pocket ID is an easy-to-use OpenID Connect Certified™ and OAuth 2.0 provider that lets users sign in to your applications with passkeys.
→ Try out the [Demo](https://demo.pocket-id.org)
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC and OAuth 2.0 providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
<a href="https://openid.net/certification/certified-openid-providers-profiles">
<img src="https://github.com/user-attachments/assets/697a8b8f-61bc-412f-8222-5410da7292c7" height="80" alt="OpenID Connect Certified">
</a>
## Setup
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.

View File

@@ -2,6 +2,9 @@ version: "2"
run:
tests: true
timeout: 5m
build-tags:
- unit
- exclude_frontend
linters:
default: none
enable:

11
backend/Makefile Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: test
test:
go test -tags exclude_frontend,unit ./...
.PHONY: test-race
test-race:
CGO_ENABLED=1 go test -race -tags exclude_frontend,unit ./...
.PHONY: lint
lint:
golangci-lint run -c .golangci.yml

View File

@@ -4,9 +4,9 @@ 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.25
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
github.com/aws/smithy-go v1.27.3
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
@@ -24,9 +24,8 @@ require (
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/hashicorp/go-uuid v1.0.3
github.com/italypaleale/francis v0.1.0-beta.6
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf
github.com/italypaleale/francis v0.1.0-beta.10
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be
github.com/italypaleale/go-sql-utils v0.2.4
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
@@ -39,7 +38,7 @@ require (
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
github.com/oschwald/maxminddb-golang/v2 v2.4.0
github.com/oschwald/maxminddb-golang/v2 v2.4.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/zitadel/exifremove v0.1.0
@@ -55,10 +54,10 @@ require (
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.43.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
golang.org/x/sync v0.22.0
golang.org/x/text v0.39.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/gorm v1.31.2
gorm.io/plugin/opentelemetry v0.1.16
modernc.org/sqlite v1.53.0
)
@@ -71,15 +70,15 @@ require (
github.com/alphadose/haxmap v1.4.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
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.29 // 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
@@ -182,7 +181,7 @@ require (
github.com/prometheus/procfs v0.21.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/quic-go/webtransport-go v0.11.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -233,7 +232,7 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
golang.org/x/tools v0.47.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

View File

@@ -20,12 +20,12 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
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.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
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.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
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=
@@ -36,14 +36,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
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.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
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.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/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=
@@ -263,10 +263,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
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.6 h1:88XGnFMwQEMsGSJvI9vrGGvp//WKipXH8YPnfNHDStQ=
github.com/italypaleale/francis v0.1.0-beta.6/go.mod h1:3QQ1iSRGzvxJZ5gfVlVR1RyPDUAgzyN+m2vHeeR7OVs=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf h1:8tk4010URtPgVqwZb7CDiNsuQI71qc8t7VSS4IaxoTY=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
github.com/italypaleale/francis v0.1.0-beta.10 h1:LCYVwkZAkakv7g5ZS6TVIRH7hozr1A0eg24LXWWBJYE=
github.com/italypaleale/francis v0.1.0-beta.10/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be h1:jgu+Mdsda++LqPxz8cj8vvgiFINQ8PhFB4Q1VZpyPjs=
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
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=
@@ -410,8 +410,8 @@ github.com/ory/pop/v6 v6.4.1 h1:mxwfgwIB+kRlE4hvcoeEuxFqXZai6TWgQ23sOCBTERo=
github.com/ory/pop/v6 v6.4.1/go.mod h1:o+a3+gdnfWUd/IpFCTKidX7sRgQ6GdPmH02XYiMH8cw=
github.com/ory/x v0.0.729 h1:7ttCYNCjCdspI6X0oaxGAXoiYWSBrwGRz6w/IG8s3I4=
github.com/ory/x v0.0.729/go.mod h1:qdUK3Sp4K4nRbYJG0sEnFO1tDLN/Ct53G+ymre0JhCU=
github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8=
github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8=
github.com/oschwald/maxminddb-golang/v2 v2.4.1 h1:OffzqSABE3Sw354GdBThqDsKfpA4GWBqOY2P91V8tjI=
github.com/oschwald/maxminddb-golang/v2 v2.4.1/go.mod h1:CZK8iQQMKfy6mKOifoyUmrj4vTHnMiGVaS7hDaZZxQ0=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
@@ -444,8 +444,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -668,8 +668,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -709,8 +709,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -722,8 +722,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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
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/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=
@@ -761,8 +761,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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=

View File

@@ -0,0 +1,64 @@
package appconfig
import (
"context"
"errors"
"sync"
"github.com/gin-gonic/gin"
)
// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context
type appConfigCtxKey struct{}
type appConfigResolver func(ctx context.Context) (*AppConfigModel, error)
// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context
type AppConfigMiddleware struct {
appConfigService *AppConfigService
}
func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware {
return &AppConfigMiddleware{
appConfigService: appConfigService,
}
}
// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context
// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request
func (m *AppConfigMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
reqCtx := c.Request.Context()
// Create a cache for each request in the middleware's scope, so it's unique per each request
var (
once sync.Once
cfg *AppConfigModel
err error
)
// Note: the resolver accepts a context argument, it doesn't use the request's own
// This can be used for example for tracing
resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) {
once.Do(func() {
cfg, err = m.appConfigService.GetConfig(ctx)
})
return cfg, err
})
// Store the resolver in the request's context
c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver))
c.Next()
}
}
// FromCtx retrieves the app config from the context
func FromCtx(ctx context.Context) (*AppConfigModel, error) {
resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver)
if !ok || resolver == nil {
// Indicates a development-time error
return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler")
}
return resolver(ctx)
}

View File

@@ -0,0 +1,112 @@
package appconfig
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
// AppConfigActorType is the actor type for the AppConfig actor
const AppConfigActorType = "AppConfig"
// appConfigActor is a singleton actor that manages the dynamic app configuration
type appConfigActor struct {
log *slog.Logger
client actor.Client[*AppConfigModel]
}
// appConfigActorBootstrap is the type for the payload of the init method
type appConfigActorBootstrap struct {
LegacyConfig map[string]string
}
// NewAppConfigActor allocates a new AppConfig actor
// It satisfies actor.Factory
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
log := slog.
With(
slog.String("scope", "actor"),
slog.String("actorType", AppConfigActorType),
slog.String("actorID", actorID),
)
log.Info("AppConfig actor created")
return &appConfigActor{
log: log,
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
}
}
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// If we already have a state, nothing else to do
if state != nil {
return nil
}
// Check if the request data contains legacy config to init from
if data != nil {
payload := appConfigActorBootstrap{}
err = data.Decode(&payload)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': %w", err)
}
if len(payload.LegacyConfig) > 0 {
state, err = fromLegacyConfig(payload.LegacyConfig)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
}
}
}
// If we still have no state, generate a new default config
if state == nil {
state = getDefaultConfig()
}
// Save the updated state
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
// Only supported method is "get"
if method != "get" {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
// Return the state
return state, nil
}

View File

@@ -0,0 +1,47 @@
package appconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// loadLegacyConfig loads the legacy config from the database
// This was migrated to the "config_migrated" key in the kv table
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
// Retrieve the migrated config from the kv table
row := model.KV{
Key: "config_migrated",
}
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's no migrated config in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated config, nothing to do
return nil, nil
}
// The value is a JSON-encoded dictionary
res := map[string]string{}
err = json.Unmarshal([]byte(*row.Value), &res)
if err != nil {
return nil, fmt.Errorf("error parsing migrated config: %w", err)
}
if len(res) == 0 {
return nil, nil
}
return res, nil
}

View File

@@ -0,0 +1,271 @@
package appconfig
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/italypaleale/go-kit/utils"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
type AppConfigModel struct {
// General
AppName AppConfigValue `json:"appName" public:"true"`
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
AccentColor AppConfigValue `json:"accentColor" public:"true"`
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
// Email
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
SmtpHost AppConfigValue `json:"smtpHost"`
SmtpPort AppConfigValue `json:"smtpPort"`
SmtpFrom AppConfigValue `json:"smtpFrom"`
SmtpUser AppConfigValue `json:"smtpUser"`
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
SmtpTls AppConfigValue `json:"smtpTls"`
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
// LDAP
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
LdapUrl AppConfigValue `json:"ldapUrl"`
LdapBindDn AppConfigValue `json:"ldapBindDn"`
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
LdapBase AppConfigValue `json:"ldapBase"`
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
}
// AppConfigValue holds a value
type AppConfigValue string
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a AppConfigValue) IsTrue() bool {
return utils.IsTruthy(string(a))
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a AppConfigValue) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(string(a))
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
// String implements fmt.Stringer
func (a AppConfigValue) String() string {
return string(a)
}
func getDefaultConfig() *AppConfigModel {
// Values are the default ones
return &AppConfigModel{
// General
AppName: "Pocket ID",
SessionDuration: "60",
HomePageURL: "/settings/account",
EmailsVerified: "false",
DisableAnimations: "false",
AllowOwnAccountEdit: "true",
AllowUserSignups: "disabled",
SignupDefaultUserGroupIDs: "[]",
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
// Email
RequireUserEmail: "true",
SmtpHost: "",
SmtpPort: "",
SmtpFrom: "",
SmtpUser: "",
SmtpPassword: "",
SmtpTls: "none",
SmtpSkipCertVerify: "false",
EmailLoginNotificationEnabled: "false",
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
// LDAP
LdapEnabled: "false",
LdapUrl: "",
LdapBindDn: "",
LdapBindPassword: "",
LdapBase: "",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapSkipCertVerify: "false",
LdapAttributeUserUniqueIdentifier: "",
LdapAttributeUserUsername: "",
LdapAttributeUserEmail: "",
LdapAttributeUserFirstName: "",
LdapAttributeUserLastName: "",
LdapAttributeUserDisplayName: "cn",
LdapAttributeUserProfilePicture: "",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "",
LdapAttributeGroupName: "",
LdapAdminGroupName: "",
LdapSoftDeleteUsers: "true",
}
}
// fromLegacyConfig builds an appConfigModel from a legacy config map
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
// Keys that are missing (or have an empty value) retain the default value
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
// Start from the default configuration, then override with the values from the legacy config
dest := getDefaultConfig()
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Look up the value in the legacy config
// If the key is missing or the value is empty, we keep the default value
value, ok := legacyCfg[key]
if !ok || value == "" {
continue
}
// Cast the string value to the field's type
fv := rv.Field(i)
switch fv.Kind() { //nolint:exhaustive
case reflect.String:
fv.SetString(value)
case reflect.Bool:
fv.SetBool(utils.IsTruthy(value))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
}
fv.SetInt(n)
default:
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
}
}
return dest, nil
}
// Replace updates every configuration property with the values from the input DTO
// An empty string value resets the corresponding property to its default value
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) error {
// Collect the values from the input DTO into a map, keyed by the "json" tag
inRv := reflect.ValueOf(input)
inRt := inRv.Type()
values := make(map[string]string, inRt.NumField())
for i := range inRt.NumField() {
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
values[key] = inRv.Field(i).String()
}
// Iterate through all the properties, setting each one from the input
// Properties that are missing from the input or have an empty value are reset to their default
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
value, ok := values[key]
if !ok || value == "" {
value = defaults.Field(i).String()
}
rv.Field(i).SetString(value)
}
return nil
}
// Update sets configuration properties from the provided key-value pairs
// Keys correspond to the "json" tags on the model
// An empty string value resets the property to its default value
func (m *AppConfigModel) Update(keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
// Iterate through the key-value pairs
// (Note the += 2, as we are iterating through key-value pairs)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Find the field in the struct whose "json" tag matches
fieldIdx := -1
for j := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
if tagValue == key {
fieldIdx = j
break
}
}
if fieldIdx < 0 {
return AppConfigKeyNotFoundError{field: key}
}
// An empty string means we use the default value for the property
if value == "" {
value = defaults.Field(fieldIdx).String()
}
rv.Field(fieldIdx).SetString(value)
}
return nil
}
type AppConfigKeyNotFoundError struct {
field string
}
func (e AppConfigKeyNotFoundError) Error() string {
return "cannot find config key '" + e.field + "'"
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
return ok
}

View File

@@ -0,0 +1,201 @@
package appconfig
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
var input dto.AppConfigUpdateDto
rv := reflect.ValueOf(&input).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
rv.Field(i).SetString("marker-" + key)
}
return input
}
func TestAppConfigModel_Replace(t *testing.T) {
t.Run("populates every property from the DTO", func(t *testing.T) {
input := dtoWithMarkerValues()
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
// Each model property must hold the marker built from its own "json" key.
// This also asserts that the model and the DTO share the same set of keys.
rv := reflect.ValueOf(&m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
}
})
t.Run("empty values fall back to their default", func(t *testing.T) {
defaults := getDefaultConfig()
// Start from all-markers, then blank out a few properties whose default is non-empty
input := dtoWithMarkerValues()
input.AppName = ""
input.SessionDuration = ""
input.SmtpTls = ""
input.LdapUserSearchFilter = ""
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
// Blanked properties are reset to their default
assert.Equal(t, defaults.AppName, m.AppName)
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
// A property that was provided keeps the provided value
assert.Equal(t, "marker-homePageUrl", m.HomePageURL)
})
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
// Pre-populate with junk to prove Replace overwrites existing state
m := AppConfigModel{
AppName: "Custom Name",
LdapEnabled: "true",
SmtpHost: "smtp.example.com",
}
err := m.Replace(dto.AppConfigUpdateDto{})
require.NoError(t, err)
assert.Equal(t, *getDefaultConfig(), m)
})
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
m := getDefaultConfig()
m.AppName = "Old Name"
m.LdapEnabled = "true"
input := dto.AppConfigUpdateDto{}
input.AppName = "New Name"
err := m.Replace(input)
require.NoError(t, err)
// Explicitly provided value wins
assert.Equal(t, "New Name", m.AppName)
// Everything else in the DTO was empty, so it is reset to the default
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
input := dto.AppConfigUpdateDto{}
input.SessionDuration = "120" // int-tagged property
input.LdapEnabled = "true" // bool-tagged property
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
assert.Equal(t, "120", m.SessionDuration)
assert.Equal(t, "true", m.LdapEnabled)
})
}
func TestAppConfigModel_Update(t *testing.T) {
t.Run("updates a single property", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "My App")
require.NoError(t, err)
assert.Equal(t, "My App", m.AppName)
})
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "My App", "homePageUrl", "/home", "ldapEnabled", "true")
require.NoError(t, err)
assert.Equal(t, "My App", m.AppName)
assert.Equal(t, "/home", m.HomePageURL)
assert.Equal(t, "true", m.LdapEnabled)
// A property that was not part of the update keeps its previous value
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("an empty value resets the property to its default", func(t *testing.T) {
m := getDefaultConfig()
m.SmtpTls = "tls" // default is "none"
m.SessionDuration = "120" // default is "60"
err := m.Update("smtpTls", "", "sessionDuration", "")
require.NoError(t, err)
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("sessionDuration", "120", "disableAnimations", "true")
require.NoError(t, err)
assert.Equal(t, "120", m.SessionDuration)
assert.Equal(t, "true", m.DisableAnimations)
})
t.Run("later value wins for a repeated key", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "First", "appName", "Second")
require.NoError(t, err)
assert.Equal(t, "Second", m.AppName)
})
t.Run("no arguments is a no-op", func(t *testing.T) {
m := getDefaultConfig()
before := *m
err := m.Update()
require.NoError(t, err)
assert.Equal(t, before, *m)
})
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
m := getDefaultConfig()
before := *m
err := m.Update("appName")
require.Error(t, err)
assert.EqualError(t, err, "invalid number of arguments received")
// The config must not have been modified
assert.Equal(t, before, *m)
})
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("thisKeyDoesNotExist", "value")
require.Error(t, err)
assert.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
require.True(t, ok)
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
})
}

View File

@@ -0,0 +1,301 @@
package appconfig
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AppConfigService struct {
actSvc *actor.Service
envConfig *AppConfigModel
}
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
service = &AppConfigService{}
// If the UI config is disabled, we do not need to init the config actor
if common.EnvConfig.UiConfigDisabled {
service.envConfig, err = service.loadDbConfigFromEnv()
if err != nil {
return nil, fmt.Errorf("error loading app config from the env: %w", err)
}
return service, nil
}
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
defer tracing.End(span, err)
// Load the legacy config if any, which we need to send to the actor as bootstrap data
legacyCfg, err := LoadLegacyConfig(ctx, db)
if err != nil {
return nil, fmt.Errorf("error loading legacy config: %w", err)
}
// Register the AppConfig actor
// This is a singleton actor and it's bootstrapped with the legacy config if present
bootstrapData := &appConfigActorBootstrap{
LegacyConfig: legacyCfg,
}
err = actors.RegisterSingletonActor(
AppConfigActorType, NewAppConfigActor,
local.WithBootstrapData(bootstrapData),
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
}
service.actSvc = actors.Service()
return service, nil
}
// GetConfig returns the application configuration
// Important: Treat the object as read-only: do not modify its properties directly!
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
return s.envConfig, nil
}
// Retrieve the config from the actor
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
if err != nil {
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
}
if res == nil {
return nil, errors.New("config actor response was empty")
}
var cfg AppConfigModel
err = res.Decode(&cfg)
if err != nil {
return nil, fmt.Errorf("error decoding config actor response: %w", err)
}
return &cfg, nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx.
WithContext(ctx).
Clauses(clause.OnConflict{
// Perform an "upsert" if the key already exists, replacing the value
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&dbUpdate).
Error
if err != nil {
return fmt.Errorf("failed to update config in database: %w", err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := getDefaultConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for field := range rt.Fields() {
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue)
} else {
err = cfg.UpdateField(key, value)
}
if err != nil {
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return nil, err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil
}
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return err
}
defer tx.Rollback()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, err := defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
if value == "" {
err = cfg.UpdateField(key, defaultValue)
} else {
err = cfg.UpdateField(key, value)
}
if err != nil {
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
return nil
}
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
}
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
// First, start from the default configuration
dest := getDefaultConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the key and internal tag values
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).Set(reflect.ValueOf(value))
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).Set(reflect.ValueOf(string(b)))
continue
}
}
}
return dest, nil
}

View File

@@ -0,0 +1,18 @@
//go:build unit
// This file contains utils for unit tests and it's only built when the "unit" tag is set
package appconfig
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
if config == nil {
// If there's no config, set the default one
config = getDefaultConfig()
}
service := &AppConfigService{
envConfig: config,
}
return service
}

View File

@@ -18,7 +18,6 @@ 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/middleware"
"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/utils/crypto"
)
@@ -28,7 +27,7 @@ type NewActorsOpts struct {
Postgres *pgxpool.Pool
EnvConfig *common.EnvConfigSchema
AppConfig *service.AppConfigService
InstanceID string
HttpClient *http.Client
DB *gorm.DB
FileStorage storage.FileStorage
@@ -52,20 +51,6 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
local.WithShutdownGracePeriod(10 * time.Second),
}
// Add all cron jobs
cronjobs, err := o.getCronJobs()
if err != nil {
return nil, nil, err
}
opts = append(opts, cronjobs...)
// Add the rate limiters
rateLimiters, rateLimiterOpts, err := o.getRateLimiters()
if err != nil {
return nil, nil, err
}
opts = append(opts, rateLimiterOpts...)
// Add the database connection
providerOpt, err := o.getProvider()
if err != nil {
@@ -79,6 +64,18 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
}
// Add all cron jobs
err = o.registerCronJobs(h)
if err != nil {
return nil, nil, err
}
// Add the rate limiters
rateLimiters, err := o.registerRateLimiters(h)
if err != nil {
return nil, nil, err
}
// Bind a service for each rate limiter so the middleware can invoke them
rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
for name, rl := range rateLimiters {
@@ -90,8 +87,9 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
// Derive a PSK from the global encryption key
func (o *NewActorsOpts) getPSK() ([]byte, error) {
// This is tied to the instance ID of the Pocket ID deployment/cluster
// Note: changing the key derivation or the seed is a breaking change
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk")
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
}
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
@@ -111,37 +109,44 @@ func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
}
}
func (o *NewActorsOpts) getCronJobs() (opts []local.HostOption, err error) {
func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
// In test mode, we do not register anything
if common.EnvConfig.AppEnv == "test" {
return opts, nil
return nil
}
// Register the analytics job
analyticsJob, err := job.GetAnalyticsJob(o.AppConfig, o.HttpClient)
analyticsJob, err := job.GetAnalyticsJob(o.HttpClient, o.InstanceID)
if err != nil {
return nil, fmt.Errorf("failed to get analytics cron job: %w", err)
return fmt.Errorf("failed to get analytics cron job: %w", err)
}
// This could be nil if analytics are disabled
if analyticsJob != nil {
// This could be nil if analytics are disabled
opts = append(opts, local.WithBuiltInActor(analyticsJob))
err = host.RegisterBuiltInActor(analyticsJob)
if err != nil {
return fmt.Errorf("error registering built-in actor for analytics job: %w", err)
}
}
// Register the file cleanup jobs
fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage)
if err != nil {
return nil, fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
return fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
}
for _, j := range fileCleanupJobs {
opts = append(opts, local.WithBuiltInActor(j))
err = host.RegisterBuiltInActor(j)
if err != nil {
return fmt.Errorf("error registering built-in actor for cleanup job: %w", err)
}
}
return opts, nil
return nil
}
// getRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
// registerRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
func (o *NewActorsOpts) getRateLimiters() (actors map[string]*ratelimit.RateLimit, opts []local.HostOption, err error) {
func (o *NewActorsOpts) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, err error) {
policies := middleware.RateLimitPolicies()
actors = make(map[string]*ratelimit.RateLimit, len(policies))
for _, p := range policies {
@@ -152,11 +157,15 @@ func (o *NewActorsOpts) getRateLimiters() (actors map[string]*ratelimit.RateLimi
ratelimit.WithBurst(p.Burst),
)
if err != nil {
return nil, nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
return nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
}
actors[p.Name] = rl
opts = append(opts, local.WithBuiltInActor(rl))
err = host.RegisterBuiltInActor(rl)
if err != nil {
return nil, fmt.Errorf("error registering built-in actor for rate limiter '%s': %w", p.Name, err)
}
}
return actors, opts, nil
return actors, nil
}

View File

@@ -14,13 +14,15 @@ func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
EnvConfig: &common.EnvConfigSchema{
EncryptionKey: []byte("test-encryption-key"),
},
// Constant value for this test
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
}
expectedHex := "651300d35d48998d0fa66ac89091bcde8ed0fd0aa35fbb849f068410c64807e9"
expectedHex := "db09067fa194c3731bf77b6415a1c5d903f03d4557605ba3236b31f6eddfc8d7"
expected, err := hex.DecodeString(expectedHex)
require.NoError(t, err)
actual, err := opts.getPSK()
require.NoError(t, err)
require.Equal(t, expected, actual)
require.Equalf(t, expected, actual, "actual result: %s", actual)
}

View File

@@ -9,10 +9,12 @@ import (
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/italypaleale/francis/host/local"
"github.com/italypaleale/go-kit/servicerunner"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
@@ -46,6 +48,13 @@ func Bootstrap(ctx context.Context) error {
}()
}
// Load the instance ID
// This is stored in the "kv" table, and generated on first startup
instanceID, err := instanceid.Load(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize instance ID: %w", err)
}
// Init storage
fileStorage, err := InitStorage(ctx, db)
if err != nil {
@@ -64,8 +73,34 @@ func Bootstrap(ctx context.Context) error {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
// Init the actors
// The actor host is created and started before the services, so services can depend on it once it's ready
actorsOpts := NewActorsOpts{
Postgres: pg,
EnvConfig: &common.EnvConfig,
InstanceID: instanceID,
HttpClient: httpClient,
DB: db,
FileStorage: fileStorage,
}
if pg == nil {
actorsOpts.SQLite, err = db.DB()
if err != nil {
return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
}
actors, rateLimitServices, err := NewActors(actorsOpts)
if err != nil {
return fmt.Errorf("failed to initialize actors: %w", err)
}
// Run the actor host as a background service and get a "ready" signal that other services can wait on
actorsRun, actorsReady := actorsRunServiceFn(actors)
services = append(services, actorsRun)
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
@@ -93,28 +128,6 @@ func Bootstrap(ctx context.Context) error {
return nil
})
// Init the actors
actorsOpts := NewActorsOpts{
Postgres: pg,
EnvConfig: &common.EnvConfig,
AppConfig: svc.appConfigService,
HttpClient: httpClient,
DB: db,
FileStorage: fileStorage,
}
if pg == nil {
actorsOpts.SQLite, err = db.DB()
if err != nil {
return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
}
actors, rateLimitServices, err := NewActors(actorsOpts)
if err != nil {
return fmt.Errorf("failed to initialize actors: %w", err)
}
services = append(services, actors.Run)
// Register scheduled jobs, only in non-test mode
if common.EnvConfig.AppEnv != "test" {
err = registerScheduledJobs(ctx, db, svc, scheduler)
@@ -131,7 +144,9 @@ func Bootstrap(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
services = append(services, router)
// The router must wait on the actor host being ready, since the rate-limit middleware invokes actors
services = append(services, actorsReady.Await(router))
// Run all background services
// This call blocks until the context is canceled
@@ -146,6 +161,37 @@ func Bootstrap(ctx context.Context) error {
return nil
}
// actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on
func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) {
actorsReady := servicerunner.NewReady()
fn := func(ctx context.Context) error {
runErrCh := make(chan error, 1)
go func() {
runErrCh <- actors.Run(ctx)
}()
// Wait for the right signal
select {
case <-actors.Ready():
// Actor host is ready, signal actorsReady
actorsReady.Signal()
case runErr := <-runErrCh:
// Run returned with an error
return runErr
case <-ctx.Done():
// Context canceled
return ctx.Err()
}
// Now the actor host is running
// This goroutine must stay up until the actor host returns
// Here, context cancellation will surface through this channel too
return <-runErrCh
}
return fn, actorsReady
}
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:

View File

@@ -68,7 +68,10 @@ func initEngine() (*gin.Engine, error) {
r := gin.New()
initLogger(r)
configureEngine(r)
err := configureEngine(r)
if err != nil {
return nil, err
}
registerGlobalMiddleware(r)
return r, nil
@@ -86,9 +89,10 @@ func setGinMode() {
}
}
func configureEngine(r *gin.Engine) {
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
func configureEngine(r *gin.Engine) error {
err := r.SetTrustedProxies(common.EnvConfig.TrustProxy)
if err != nil {
return fmt.Errorf("failed to configure trusted proxies: %w", err)
}
if common.EnvConfig.TrustedPlatform != "" {
@@ -99,6 +103,8 @@ func configureEngine(r *gin.Engine) {
common.Name,
otelgin.WithFilter(shouldTraceRequest)),
)
return nil
}
// shouldTraceRequest reports whether an incoming request should be traced.

View File

@@ -5,12 +5,14 @@ import (
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/api"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"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/job"
"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/storage"
@@ -19,7 +21,7 @@ import (
)
type services struct {
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
@@ -44,10 +46,20 @@ type services struct {
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
func initServices(
ctx context.Context,
db *gorm.DB,
instanceID string,
actors *local.Host,
httpClient *http.Client,
imageExtensions map[string]string,
fileStorage storage.FileStorage,
scheduler *job.Scheduler,
) (svc *services, err error) {
svc = &services{}
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
// Init the app config service
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
@@ -63,7 +75,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService, err = service.NewJwtService(ctx, db, svc.appConfigService)
svc.jwtService, err = service.NewJwtService(ctx, db, instanceID, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
@@ -88,9 +100,10 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
DB: db,
HTTPClient: httpClient,
Config: oidc.Config{
BaseURL: common.EnvConfig.AppURL,
TokenBaseURL: common.EnvConfig.AppURL,
Secret: common.EnvConfig.EncryptionKey,
BaseURL: common.EnvConfig.AppURL,
TokenBaseURL: common.EnvConfig.AppURL,
Secret: common.EnvConfig.EncryptionKey,
AllowInsecureCallbackURLs: common.EnvConfig.AllowInsecureCallbackURLs,
},
Signer: svc.jwtService,
CustomClaims: svc.customClaimService,

View File

@@ -6,14 +6,14 @@ import (
"fmt"
"os"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"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/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
@@ -35,7 +35,12 @@ func init() {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, &common.EnvConfig)
instanceID, err := instanceid.Load(cmd.Context(), db)
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, instanceID, &common.EnvConfig)
},
}
@@ -45,7 +50,7 @@ func init() {
rootCmd.AddCommand(encryptionKeyRotateCmd)
}
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) error {
oldKey := envConfig.EncryptionKey
newKey := []byte(flags.NewKey)
if len(newKey) == 0 {
@@ -67,12 +72,6 @@ func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db
}
}
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Derive the encryption keys used for the JWK encryption
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
if err != nil {

View File

@@ -4,13 +4,13 @@ import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"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/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -25,9 +25,8 @@ func TestEncryptionKeyRotate(t *testing.T) {
db := testingutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
require.NoError(t, err)
@@ -61,7 +60,8 @@ func TestEncryptionKeyRotate(t *testing.T) {
NewKey: string(newKey),
Yes: true,
}
require.NoError(t, encryptionKeyRotate(t.Context(), flags, db, envConfig))
err = encryptionKeyRotate(t.Context(), flags, db, instanceID, envConfig)
require.NoError(t, err)
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
require.NoError(t, err)

View File

@@ -13,7 +13,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/service"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
@@ -36,7 +36,12 @@ func init() {
return err
}
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
instanceID, err := instanceid.Load(cmd.Context(), db)
if err != nil {
return err
}
return keyRotate(cmd.Context(), flags, db, instanceID, &common.EnvConfig)
},
}
@@ -47,7 +52,7 @@ func init() {
rootCmd.AddCommand(keyRotateCmd)
}
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) error {
// Validate the flags
switch strings.ToUpper(flags.Alg) {
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
@@ -83,14 +88,8 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
}
// Init the services we need
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -81,17 +81,16 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService, err := service.NewAppConfigService(t.Context(), db)
// Load the instance ID
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Get key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err)
// Run the key rotation
err = keyRotate(t.Context(), flags, db, envConfig)
err = keyRotate(t.Context(), flags, db, instanceID, envConfig)
if wantErr {
require.Error(t, err)

View File

@@ -8,6 +8,7 @@ import (
"net/url"
"os"
"reflect"
"strconv"
"strings"
"github.com/caarlos0/env/v11"
@@ -17,6 +18,7 @@ import (
type AppEnv string
type DbProvider string
type TrustProxyConfig []string
const (
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
@@ -38,21 +40,22 @@ const (
)
type EnvConfigSchema struct {
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy bool `env:"TRUST_PROXY"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
AllowInsecureCallbackURLs bool `env:"ALLOW_INSECURE_CALLBACK_URLS"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
@@ -97,18 +100,19 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
ActorsPort: "1414",
ActorsHost: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AllowInsecureCallbackURLs: true, // TODO: Default to false in major v3
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
ActorsPort: "1414",
ActorsHost: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
@@ -390,3 +394,29 @@ func (a AppEnv) IsProduction() bool {
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}
func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
value := strings.TrimSpace(string(text))
// Support boolean values for completely enabling or disabling trust proxy
enabled, err := strconv.ParseBool(value)
if err == nil {
if enabled {
*config = TrustProxyConfig{"0.0.0.0/0", "::/0"}
} else {
*config = nil
}
return nil
}
// Normalize and validate each explicit proxy before the server starts
proxies := strings.Split(value, ",")
for i, proxy := range proxies {
proxy = strings.TrimSpace(proxy)
proxies[i] = proxy
}
*config = proxies
return nil
}

View File

@@ -119,12 +119,36 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.TrustProxy)
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
})
t.Run("should parse trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("TRUST_PROXY", "10.0.0.0/8, 192.168.1.10, ::1/128")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.TrustProxy)
})
t.Run("should disable trusted proxies when set to false", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("TRUST_PROXY", "false")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Nil(t, EnvConfig.TrustProxy)
})
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
})
t.Run("should default audit log retention days to 90", func(t *testing.T) {

View File

@@ -0,0 +1,20 @@
package common
import (
"errors"
)
// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported
type ErrUnsupportedActorMethod struct {
Method string
}
func (e ErrUnsupportedActorMethod) Error() string {
return "method '" + e.Method + "' unsupported for actor invocation"
}
func (e ErrUnsupportedActorMethod) Is(target error) bool {
// Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod
_, ok := errors.AsType[ErrUnsupportedActorMethod](target)
return ok
}

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
@@ -19,7 +20,7 @@ import (
func NewAppConfigController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appConfigService *service.AppConfigService,
appConfigService *appconfig.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
@@ -38,7 +39,7 @@ func NewAppConfigController(
}
type AppConfigController struct {
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}

View File

@@ -1,9 +1,11 @@
package controller
import (
"fmt"
"net/http"
"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/utils/cookie"
@@ -21,7 +23,7 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *appconfig.AppConfigService) {
uc := UserController{
userService: userService,
oneTimeAccessService: oneTimeAccessService,
@@ -64,7 +66,7 @@ type UserController struct {
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *webauthn.Module
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
}
// getUserGroupsHandler godoc
@@ -508,6 +510,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
cfg, err := appconfig.FromCtx(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
@@ -523,12 +531,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)

View File

@@ -0,0 +1,50 @@
package instanceid
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Load the instance ID for the Pocket ID cluster from the "kv" table
// If no instance ID exists yet, a new one is generated and persisted atomically
func Load(parentCtx context.Context, db *gorm.DB) (string, error) {
// Candidate value used only if there's no instance ID stored yet
newInstanceID := uuid.NewString()
// We use a raw query because gorm can't build it for us in this atomic way
// The syntax is valid for both SQLite and Postgres
var value string
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err := db.
WithContext(ctx).
Raw(
`INSERT INTO kv (key, value)
VALUES ('instance_id', ?)
ON CONFLICT (key) DO UPDATE SET
value = CASE
WHEN kv.value = '' THEN excluded.value
ELSE kv.value
END
RETURNING value`,
newInstanceID,
).
Scan(&value).
Error
if err != nil {
return "", fmt.Errorf("failed to load instance ID from the database: %w", err)
}
// We should have an instance ID now
// This case should never happen
if value == "" {
return "", errors.New("retrieved instance ID is unexpectedly empty")
}
return value, nil
}

View File

@@ -0,0 +1,190 @@
package instanceid
import (
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// readInstanceID returns the value stored in the kv table under the "instance_id" key, together with the number of rows that match (to detect duplicates)
func readInstanceID(t *testing.T, db *gorm.DB) (value string, count int) {
t.Helper()
var rows []model.KV
err := db.Where("key = ?", "instance_id").Find(&rows).Error
require.NoError(t, err)
if len(rows) == 0 {
return "", 0
}
require.NotNil(t, rows[0].Value, "instance_id value should not be NULL")
return *rows[0].Value, len(rows)
}
func TestLoad(t *testing.T) {
t.Run("generates and persists a new instance ID when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.NotEmpty(t, id)
// The generated ID should be a valid UUID
_, err = uuid.Parse(id)
require.NoError(t, err, "generated instance ID should be a valid UUID")
// The value should have been persisted in the kv table
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, id, stored)
})
t.Run("returns the existing instance ID on subsequent calls", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
first, err := Load(t.Context(), db)
require.NoError(t, err)
require.NotEmpty(t, first)
// A second call must return the same ID and must not create a duplicate row
second, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, first, second)
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, first, stored)
})
t.Run("returns an instance ID that was already stored", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Seed an instance ID directly in the database, e.g. as written by the migration that moved it out of the app config table
existing := "existing-instance-id"
err := db.Create(&model.KV{Key: "instance_id", Value: &existing}).Error
require.NoError(t, err)
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, existing, id)
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, existing, stored)
})
t.Run("concurrent calls without an existing instance ID converge on a single value", func(t *testing.T) {
// This needs a database that allows concurrent access, so the goroutines race
db := testutils.NewConcurrentDatabaseForTest(t)
const parallel = 25
// Each goroutine writes to its own index, so no locking is needed to collect the results
ids := make([]string, parallel)
loadErrs := make([]error, parallel)
// The start channel is a barrier so all goroutines race their Load call at once
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(parallel)
for i := range parallel {
go func() {
defer wg.Done()
<-start
ids[i], loadErrs[i] = Load(t.Context(), db)
}()
}
close(start)
wg.Wait()
// No concurrent call should have failed
for i, err := range loadErrs {
require.NoErrorf(t, err, "goroutine %d errored", i)
}
// Every goroutine must have observed the same, non-empty instance ID, meaning only one of them actually generated and persisted a value
first := ids[0]
require.NotEmpty(t, first)
for _, id := range ids {
require.Equal(t, first, id)
}
// Only a single row must have been persisted, and it must match the shared value
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, first, stored)
})
}
// TestMigrateFromAppConfig validates the database migration that moves the instance ID out of the legacy app_config_variables table and into the kv table
// The value used to be stored as an app config variable under the "instanceId" key
func TestMigrateFromAppConfig(t *testing.T) {
// Version of the migration right before "20260708130000_move_instance_id_to_kv", which performs the move
// We seed the legacy table right after this version, so the following migration has data to operate on
const versionBeforeMove uint = 20260708120000
// seedAppConfigInstanceID inserts a legacy "instanceId" row into the app_config_variables table
seedAppConfigInstanceID := func(value string) func(t *testing.T, db *gorm.DB) {
return func(t *testing.T, db *gorm.DB) {
t.Helper()
err := db.Exec(`INSERT INTO app_config_variables ("key", "value") VALUES ('instanceId', ?)`, value).Error
require.NoError(t, err)
}
}
// countLegacyInstanceID returns the number of "instanceId" rows left in the app_config_variables table
countLegacyInstanceID := func(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var count int64
err := db.Table("app_config_variables").Where(`"key" = ?`, "instanceId").Count(&count).Error
require.NoError(t, err)
return count
}
t.Run("moves an existing instance ID from app_config_variables into the kv table", func(t *testing.T) {
legacyID := uuid.NewString()
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, seedAppConfigInstanceID(legacyID))
// The migration should have copied the value into the kv table under the "instance_id" key
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, legacyID, stored)
// The legacy row must have been removed from app_config_variables
require.Zero(t, countLegacyInstanceID(t, db))
// Load must return the migrated value without generating a new one
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, legacyID, id)
stored, count = readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, legacyID, stored)
})
t.Run("keeps the existing kv value when both tables have an instance ID", func(t *testing.T) {
legacyID := uuid.NewString()
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, func(t *testing.T, db *gorm.DB) {
t.Helper()
// An instance ID is already present in the kv table before the move runs
err := db.Exec(`INSERT INTO kv ("key", "value") VALUES ('instance_id', ?)`, "kv-instance-id").Error
require.NoError(t, err)
// And a different value lingers in the legacy app_config_variables table
seedAppConfigInstanceID(legacyID)(t, db)
})
// The migration must not clobber the value already stored in the kv table
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, "kv-instance-id", stored)
// The legacy row must still be removed regardless of the conflict
require.Zero(t, countLegacyInstanceID(t, db))
})
}

View File

@@ -13,13 +13,12 @@ import (
"github.com/italypaleale/francis/builtin/cronjob"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
// GetAnalyticsJob returns the CronJob actor
func GetAnalyticsJob(appConfig *service.AppConfigService, httpClient *http.Client) (*cronjob.CronJob, error) {
func GetAnalyticsJob(httpClient *http.Client, instanceID string) (*cronjob.CronJob, error) {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil, nil
@@ -28,7 +27,7 @@ func GetAnalyticsJob(appConfig *service.AppConfigService, httpClient *http.Clien
job := &AnalyticsJob{
httpClient: httpClient,
}
err := job.createBody(appConfig)
err := job.createBody(instanceID)
if err != nil {
return nil, fmt.Errorf("error pre-computing request body: %w", err)
}
@@ -56,13 +55,13 @@ type AnalyticsJob struct {
}
// createBody pre-computes the body for all requests
func (j *AnalyticsJob) createBody(appConfig *service.AppConfigService) error {
func (j *AnalyticsJob) createBody(instanceID string) error {
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: appConfig.GetDbConfig().InstanceID.Value,
InstanceID: instanceID,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)

View File

@@ -8,17 +8,18 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
type ApiKeyEmailJobs struct {
apiKeyModule *apikey.Module
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
emailService *service.EmailService
}
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error {
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error {
jobs := &ApiKeyEmailJobs{
apiKeyModule: apiKeyModule,
appConfigService: appConfigService,
@@ -30,8 +31,13 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *a
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
dbConfig, err := j.appConfigService.GetConfig(ctx)
if err != nil {
return fmt.Errorf("error load app config: %w", err)
}
// Skip if the feature is disabled
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() {
return nil
}

View File

@@ -2,17 +2,19 @@ package job
import (
"context"
"fmt"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type LdapJobs struct {
ldapService *service.LdapService
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
}
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *appconfig.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour (with some jitter)
@@ -20,7 +22,12 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {
if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
dbConfig, err := j.appConfigService.GetConfig(ctx)
if err != nil {
return fmt.Errorf("error load app config: %w", err)
}
if !dbConfig.LdapEnabled.IsTrue() {
return nil
}

View File

@@ -12,7 +12,9 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"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/instanceid"
"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/service"
@@ -32,10 +34,12 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
appConfigService := appconfig.NewTestAppConfigService(nil)
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
jwtService, err := service.NewJwtService(t.Context(), db, instanceID, appConfigService)
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
@@ -45,7 +49,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService)
user := createUserForAuthMiddlewareTest(t, db)
jwtToken, err := jwtService.GenerateAccessToken(user, "")
jwtToken, err := jwtService.GenerateAccessToken(user, "", time.Hour)
require.NoError(t, err)
apiKeyToken := "middleware-test-api-key-raw-token"

View File

@@ -23,21 +23,23 @@ func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[strin
t.Helper()
limiters := make(map[string]*ratelimit.RateLimit, len(policies))
opts := make([]local.HostOption, 0, len(policies))
for _, p := range policies {
rl, err := ratelimit.New(p.Name, ratelimit.WithRate(p.Rate), ratelimit.WithPer(p.Per), ratelimit.WithBurst(p.Burst))
require.NoError(t, err)
limiters[p.Name] = rl
opts = append(opts, local.WithBuiltInActor(rl))
}
h := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
for _, p := range policies {
rl, err := ratelimit.New(p.Name, ratelimit.WithRate(p.Rate), ratelimit.WithPer(p.Per), ratelimit.WithBurst(p.Burst))
require.NoError(t, err)
limiters[p.Name] = rl
h := testutils.NewActorHostForTest(t, opts...)
err = h.RegisterBuiltInActor(rl)
require.NoError(t, err)
}
})
services := make(map[string]*ratelimit.RateLimitService, len(limiters))
svc := h.Service()
for name, rl := range limiters {
services[name] = rl.Service(svc)
}
return services
}

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -44,8 +43,6 @@ type AppConfig struct {
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
// Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
SmtpHost AppConfigVariable `key:"smtpHost"`
@@ -88,7 +85,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
var res []AppConfigVariable
res := make([]AppConfigVariable, 0, cfgType.NumField())
for i := range cfgType.NumField() {
field := cfgType.Field(i)
@@ -121,7 +118,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues
return res
}
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
func (c *AppConfig) FieldByKey(key string) (defaultValue string, err error) {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
@@ -130,36 +127,30 @@ func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool
// Grab only the first part of the key, if there's a comma with additional properties
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
keyFromTag := tagValue[0]
isInternal = slices.Contains(tagValue, "internal")
if keyFromTag != key {
continue
}
valueField := rv.Field(i).FieldByName("Value")
return valueField.String(), isInternal, nil
return valueField.String(), nil
}
// If we are here, the config key was not found
return "", false, AppConfigKeyNotFoundError{field: key}
return "", AppConfigKeyNotFoundError{field: key}
}
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
func (c *AppConfig) UpdateField(key string, value string) error {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches, then update that
for i := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key {
continue
}
// If the field is internal and noInternal is true, we skip that
if noInternal && attrs == "internal" {
return AppConfigInternalForbiddenError{field: key}
}
valueField := rv.Field(i).FieldByName("Value")
if !valueField.CanSet() {
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
@@ -186,20 +177,6 @@ func (e AppConfigKeyNotFoundError) Error() string {
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
x := AppConfigKeyNotFoundError{}
return errors.As(target, &x)
}
type AppConfigInternalForbiddenError struct {
field string
}
func (e AppConfigInternalForbiddenError) Error() string {
return "field '" + e.field + "' is internal and can't be updated"
}
func (e AppConfigInternalForbiddenError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError
x := AppConfigInternalForbiddenError{}
return errors.As(target, &x)
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
return ok
}

View File

@@ -100,7 +100,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
if strings.HasSuffix(fieldName, "ImageType") {
// Skip internal fields that shouldn't be in the DTO
continue
}

View File

@@ -13,9 +13,10 @@ import (
)
type Config struct {
BaseURL string
TokenBaseURL string
Secret []byte
BaseURL string
TokenBaseURL string
Secret []byte
AllowInsecureCallbackURLs bool
}
type TokenSigner interface {

View File

@@ -49,6 +49,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
IgnoreUnknownScopes: true,
AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy,
RedirectURIMatcher: matchRedirectURI,
RedirectSecureChecker: redirectSecureChecker(config.AllowInsecureCallbackURLs),
EnforcePKCEForPublicClients: true,
EnablePKCEPlainChallengeMethod: true,
SupportedRequestObjectSigningAlgorithms: []string{"none"},
@@ -111,6 +112,17 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
}, nil
}
func redirectSecureChecker(allowInsecureCallbackURLs bool) func(context.Context, *url.URL) bool {
return func(ctx context.Context, redirectURI *url.URL) bool {
if allowInsecureCallbackURLs || fosite.IsRedirectURISecure(ctx, redirectURI) {
return true
}
slog.InfoContext(ctx, "HTTP callback URL rejected; set ALLOW_INSECURE_CALLBACK_URLS=true to allow it", "callback_url", redirectURI.Redacted())
return false
}
}
func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) {
redirectURI, err := fosite.MatchRedirectURIWithClientRedirectURIs(rawurl, client)
if err == nil || rawurl == "" {

View File

@@ -11,6 +11,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -85,6 +86,70 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
require.Equal(t, "test-key-id", header["kid"])
}
func TestRedirectSecureChecker(t *testing.T) {
loopbackRedirectURI, err := url.Parse("http://127.0.0.1:49813/callback")
require.NoError(t, err)
checker := redirectSecureChecker(false)
require.True(t, checker(t.Context(), loopbackRedirectURI))
}
func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
tests := []struct {
name string
allowInsecureCallbackURLs bool
expectSuccess bool
}{
{
name: "allows HTTP callback URLs when compatibility is enabled",
allowInsecureCallbackURLs: true,
expectSuccess: true,
},
{
name: "rejects HTTP callback URLs when compatibility is disabled",
allowInsecureCallbackURLs: false,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: "test-client"},
Name: "Test Client",
CallbackURLs: model.UrlList{"http://client.example.com/callback"},
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
AllowInsecureCallbackURLs: tt.allowInsecureCallbackURLs,
})
require.NoError(t, err)
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri=http://client.example.com/callback",
nil,
)
authorizeRequest, err := provider.NewAuthorizeRequest(req.Context(), req)
require.NoError(t, err)
_, err = provider.NewAuthorizeResponse(t.Context(), authorizeRequest, NewEmptySession())
if tt.expectSuccess {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}
})
}
}
func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

View File

@@ -1,444 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AppConfigService struct {
dbConfig atomic.Pointer[model.AppConfig]
db *gorm.DB
}
func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigService, err error) {
service = &AppConfigService{
db: db,
}
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
defer tracing.End(span, err)
// We need to assign to the "err" variable, do not inline this into the "if"
err = service.LoadDbConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
}
err = service.initInstanceID(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
}
return service, nil
}
// GetDbConfig returns the application configuration.
// Important: Treat the object as read-only: do not modify its properties directly!
func (s *AppConfigService) GetDbConfig() *model.AppConfig {
v := s.dbConfig.Load()
if v == nil {
// This indicates a development-time error
panic("called GetDbConfig before DbConfig is loaded")
}
return v
}
func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Values are the default ones
return &model.AppConfig{
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
InstanceID: model.AppConfigVariable{Value: ""},
// Email
RequireUserEmail: model.AppConfigVariable{Value: "true"},
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
SmtpFrom: model.AppConfigVariable{},
SmtpUser: model.AppConfigVariable{},
SmtpPassword: model.AppConfigVariable{},
SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
LdapBindDn: model.AppConfigVariable{},
LdapBindPassword: model.AppConfigVariable{},
LdapBase: model.AppConfigVariable{},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapSkipCertVerify: model.AppConfigVariable{Value: "false"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeUserUsername: model.AppConfigVariable{},
LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{},
LdapAdminGroupName: model.AppConfigVariable{},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}
func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) {
// We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database
// This works across multiple processes too
tx = s.db.Begin()
err = tx.Error
if err != nil {
return nil, fmt.Errorf("failed to begin database transaction: %w", err)
}
// With SQLite there's nothing else we need to do, because a transaction blocks the entire database
// However, with Postgres we need to manually lock the table to prevent others from doing the same
switch s.db.Name() {
case "postgres":
// We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled
// Here we use a context with a 10s timeout in case the database is blocked for longer
lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second)
defer lockCancel()
err = tx.
WithContext(lockCtx).
Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE").
Error
if err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err)
}
default:
// Nothing to do here
}
return tx, nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx.
WithContext(ctx).
Clauses(clause.OnConflict{
// Perform an "upsert" if the key already exists, replacing the value
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&dbUpdate).
Error
if err != nil {
return fmt.Errorf("failed to update config in database: %w", err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for field := range rt.Fields() {
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue, true)
} else {
err = cfg.UpdateField(key, value, true)
}
// If we tried to update an internal field, ignore the error (and do not update in the DB)
if errors.Is(err, model.AppConfigInternalForbiddenError{}) {
continue
} else if err != nil {
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return nil, err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil
}
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return err
}
defer tx.Rollback()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
if !isInternal && common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
err = cfg.UpdateField(key, defaultValue, false)
} else {
err = cfg.UpdateField(key, value, false)
}
if err != nil {
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
return nil
}
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
}
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
dest, err := s.loadDbConfigInternal(ctx, s.db)
if err != nil {
return err
}
s.dbConfig.Store(dest)
return nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err := s.loadDbConfigFromEnv(ctx, tx)
return dest, err
}
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
}
return dest, nil
}
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the key and internal tag values
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
// Internal fields are loaded from the database as they can't be set from the environment
if attrs == "internal" {
var value string
err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}).
Where("key = ?", key).
Select("value").
First(&value).Error
if err == nil {
rv.Field(i).FieldByName("Value").SetString(value)
}
continue
}
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).FieldByName("Value").SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).FieldByName("Value").SetString(string(b))
continue
}
}
}
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil
}

View File

@@ -1,473 +0,0 @@
package service
import (
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"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"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
service := &AppConfigService{
dbConfig: atomic.Pointer[model.AppConfig]{},
}
service.dbConfig.Store(config)
return service
}
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
// Load the config
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be equal to default config
require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig())
})
t.Run("loads value from config table", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
Create([]model.AppConfigVariable{
// Overrides default value
{Key: "appName", Value: "Test App"},
{Key: "sessionDuration", Value: "5"},
// Does not have a default value
{Key: "smtpHost", Value: "example"},
}).
Error
require.NoError(t, err)
// Load the config
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Values should match expected ones
expect := service.getDefaultDbConfig()
expect.AppName.Value = "Test App"
expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect)
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
{Key: "__nonExistentKey", Value: "some value"},
{Key: "appName", Value: "TestApp"}, // This one should still be loaded
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// This should not fail, just ignore the unknown key
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
config := service.GetDbConfig()
require.Equal(t, "TestApp", config.AppName.Value)
})
t.Run("loading config multiple times", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "InitialApp"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value)
// Update the database value
err = db.Model(&model.AppConfigVariable{}).
Where("key = ?", "appName").
Update("value", "UpdatedApp").Error
require.NoError(t, err)
// Load the config again, it should reflect the updated value
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value)
})
t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables for testing
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Enable UiConfigDisabled to load from env
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from env, not DB
config := service.GetDbConfig()
require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env")
require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env")
})
t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables that should be ignored
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Make sure UiConfigDisabled is false to load from DB
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from DB, not env
config := service.GetDbConfig()
require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env")
require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env")
})
}
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update a single config value
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App")
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
// Verify database was updated
var dbValue model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&dbValue).Error
require.NoError(t, err)
require.Equal(t, "Test App", dbValue.Value)
})
t.Run("update multiple values", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update multiple config values
err = service.UpdateAppConfigValues(
t.Context(),
"appName", "Test App",
"sessionDuration", "30",
"smtpHost", "mail.example.com",
)
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
require.Equal(t, "30", config.SessionDuration.Value)
require.Equal(t, "mail.example.com", config.SmtpHost.Value)
// Verify database was updated
var count int64
db.Model(&model.AppConfigVariable{}).Count(&count)
require.Equal(t, int64(3), count)
var appName, sessionDuration, smtpHost model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Test App", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "30", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "mail.example.com", smtpHost.Value)
})
t.Run("empty value resets to default", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First change the value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30")
require.NoError(t, err)
require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value)
// Now set it to empty which should use default value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
require.NoError(t, err)
require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with odd number of arguments
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid number of arguments")
})
t.Run("error with invalid key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with invalid key
err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid configuration key")
})
}
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Create update DTO
input := dto.AppConfigUpdateDto{
AppName: "Updated App Name",
SessionDuration: "120",
SmtpHost: "smtp.example.com",
SmtpPort: "587",
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables
require.NotEmpty(t, updatedVars)
var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Updated App Name", v.Value)
foundAppName = true
case "sessionDuration":
require.Equal(t, "120", v.Value)
foundSessionDuration = true
case "smtpHost":
require.Equal(t, "smtp.example.com", v.Value)
foundSmtpHost = true
case "smtpPort":
require.Equal(t, "587", v.Value)
foundSmtpPort = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
require.True(t, foundSmtpHost)
require.True(t, foundSmtpPort)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Updated App Name", config.AppName.Value)
require.Equal(t, "120", config.SessionDuration.Value)
require.Equal(t, "smtp.example.com", config.SmtpHost.Value)
require.Equal(t, "587", config.SmtpPort.Value)
// Verify database was updated
var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Updated App Name", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "120", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "smtp.example.com", smtpHost.Value)
err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error
require.NoError(t, err)
require.Equal(t, "587", smtpPort.Value)
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First set some non-default values
err = service.UpdateAppConfigValues(t.Context(),
"appName", "Custom App",
"sessionDuration", "120",
)
require.NoError(t, err)
// Create update DTO with empty values to reset to defaults
input := dto.AppConfigUpdateDto{
AppName: "", // Should reset to default "Pocket ID"
SessionDuration: "", // Should reset to default "60"
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables (they should be empty strings in DB)
var foundAppName, foundSessionDuration bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Pocket ID", v.Value) // Returns the default value
foundAppName = true
case "sessionDuration":
require.Equal(t, "60", v.Value) // Returns the default value
foundSessionDuration = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
// Verify in-memory config was reset to defaults
config := service.GetDbConfig()
require.Equal(t, "Pocket ID", config.AppName.Value) // Default value
require.Equal(t, "60", config.SessionDuration.Value) // Default value
// Verify database was updated with empty values
for _, key := range []string{"appName", "sessionDuration"} {
var loaded model.AppConfigVariable
err = db.Where("key = ?", key).First(&loaded).Error
require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key)
require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key)
}
})
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update config
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
AppName: "Should Not Update",
})
// Should get a UiConfigDisabledError
require.Error(t, err)
var uiConfigDisabledErr *common.UiConfigDisabledError
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
userAgentParser "github.com/mileusna/useragent"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
@@ -14,12 +15,12 @@ import (
type AuditLogService struct {
db *gorm.DB
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
emailService *EmailService
geoliteService *GeoLiteService
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
func NewAuditLogService(db *gorm.DB, appConfigService *appconfig.AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{
db: db,
appConfigService: appConfigService,
@@ -64,7 +65,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog {
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
if !ok {
// At this point the transaction has been canceled already, and error has been logged
@@ -90,7 +91,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
if dbConfig.EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
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

View File

@@ -22,6 +22,7 @@ import (
"github.com/ory/fosite/compose"
fositejwt "github.com/ory/fosite/token/jwt"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/api"
@@ -40,7 +41,7 @@ import (
type TestService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
ldapService *LdapService
fileStorage storage.FileStorage
appLockService *AppLockService
@@ -54,7 +55,7 @@ const (
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
)
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
appConfigService: appConfigService,
@@ -630,12 +631,22 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Manually set instance ID
err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id")
// Manually set the instance ID used to derive the JWK encryption key, so the seeded JWK can be decrypted
// Persist the fixed test value so it survives an export/import round-trip
const testInstanceID = "test-instance-id"
err = s.db.WithContext(ctx).
Exec(
`INSERT INTO kv (key, value) VALUES ('instance_id', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
testInstanceID,
).
Error
if err != nil {
return err
}
// The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately
s.jwtService.instanceID = testInstanceID
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {

View File

@@ -13,19 +13,20 @@ import (
"github.com/italypaleale/go-kit/emailer"
"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"
)
type EmailService struct {
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template
}
func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailService, error) {
func NewEmailService(db *gorm.DB, appConfigService *appconfig.AppConfigService) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)

View File

@@ -54,12 +54,14 @@ func seedActorHostSchema(t *testing.T, db *gorm.DB) {
func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) {
t.Helper()
var tableRows int64
require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error)
err := db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error
require.NoError(t, err)
require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import")
// The view is only valid if its backing table was preserved as well
var viewCount int64
require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error)
err = db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error
require.NoError(t, err)
require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import")
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
@@ -47,14 +48,15 @@ type JwtService struct {
envConfig *common.EnvConfigSchema
privateKey jwk.Key
keyId string
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
instanceID string
jwksEncoded []byte
}
func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService) (*JwtService, error) {
service := &JwtService{}
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig)
if err != nil {
return nil, err
}
@@ -62,10 +64,11 @@ func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfig
return service, nil
}
func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
s.db = db
s.instanceID = instanceID
// Ensure keys are generated or loaded
return s.LoadOrGenerateKey(ctx)
@@ -73,7 +76,7 @@ func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *Ap
func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.instanceID)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
@@ -181,12 +184,11 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
return nil
}
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
Expiration(now.Add(sessionDuration)).
IssuedAt(now).
Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).

View File

@@ -1,7 +1,6 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
@@ -18,7 +17,9 @@ import (
"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/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/model"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -35,23 +36,33 @@ func newTestEnvConfig() *common.EnvConfigSchema {
}
}
func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
t.Helper()
service := &JwtService{}
err := service.init(t.Context(), db, appConfig, envConfig)
err := service.init(t.Context(), db, instanceID, appConfig, envConfig)
require.NoError(t, err, "Failed to initialize JWT service")
return service
}
func setupJwtService(t *testing.T, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
func setupJwtService(t *testing.T, instanceID string, appConfig *appconfig.AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
envConfig := newTestEnvConfig()
return initJwtService(t, db, appConfig, envConfig), db, envConfig
service := initJwtService(t, db, instanceID, appConfig, envConfig)
return service, db, envConfig
}
func newInstanceID(t *testing.T, db *gorm.DB) string {
t.Helper()
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
return instanceID
}
func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
@@ -60,10 +71,10 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
return testutils.NewDatabaseForTest(t), newTestEnvConfig()
}
func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string {
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, key jwk.Key) string {
t.Helper()
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err, "Failed to init key provider")
err = keyProvider.SaveKey(t.Context(), key)
@@ -77,22 +88,21 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
}
func TestJwtService_Init(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockConfig := appconfig.NewTestAppConfigService(nil)
t.Run("should generate new key when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Initialize the JWT service
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Verify the private key was set
require.NotNil(t, service.privateKey, "Private key should be set")
// Verify the key has been persisted in the database
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, instanceID)
require.NoError(t, err, "Failed to init key provider")
key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err, "Failed to load key from provider")
@@ -111,16 +121,17 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// First create a service to generate a key
firstService := initJwtService(t, db, mockConfig, mockEnvConfig)
firstService := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the key ID of the first service
origKeyID, ok := firstService.privateKey.KeyID()
require.True(t, ok)
// Now create a new service that should load the existing key
secondService := initJwtService(t, db, mockConfig, mockEnvConfig)
secondService := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := secondService.privateKey.KeyID()
@@ -131,12 +142,13 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK for ECDSA keys", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Create a new JWK and save it to the database
origKeyID := createECDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
origKeyID := createECDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Now create a new service that should load the existing key
svc := initJwtService(t, db, mockConfig, mockEnvConfig)
svc := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Ensure loaded key has the right algorithm
alg, ok := svc.privateKey.Algorithm()
@@ -152,12 +164,13 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK for EdDSA keys", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Create a new JWK and save it to the database
origKeyID := createEdDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
origKeyID := createEdDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Now create a new service that should load the existing key
svc := initJwtService(t, db, mockConfig, mockEnvConfig)
svc := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Ensure loaded key has the right algorithm and curve
alg, ok := svc.privateKey.Algorithm()
@@ -178,12 +191,13 @@ func TestJwtService_Init(t *testing.T) {
}
func TestJwtService_GetPublicJWK(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockConfig := appconfig.NewTestAppConfigService(nil)
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
t.Run("returns public key when private key is initialized", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -208,14 +222,11 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
})
t.Run("returns public key when ECDSA private key is initialized", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
// Create an ECDSA key and save it in the database
originalKeyID := createECDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
originalKeyID := createECDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Create a JWT service that loads the ECDSA key
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -248,10 +259,10 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
mockEnvConfig := newTestEnvConfig()
// Create an EdDSA key and save it in the database
originalKeyID := createEdDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
originalKeyID := createEdDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Create a JWT service that loads the EdDSA key
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -296,12 +307,13 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
}
func TestGenerateVerifyAccessToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
const sessionDuration = time.Hour
mockConfig := appconfig.NewTestAppConfigService(nil)
db, envConfig := newTestDbAndEnv(t)
instanceID := newInstanceID(t, db)
t.Run("generates token for regular user", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
user := model.User{
Base: model.Base{ID: "user123"},
@@ -309,7 +321,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: false,
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
require.NoError(t, err, "Failed to generate access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -342,7 +354,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("generates token for admin user", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
adminUser := model.User{
Base: model.Base{ID: "admin123"},
@@ -350,7 +362,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(adminUser, "")
tokenString, err := service.GenerateAccessToken(adminUser, "", sessionDuration)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -367,13 +379,13 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
user := model.User{
Base: model.Base{ID: "user-with-auth-method"},
}
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant, sessionDuration)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -384,33 +396,9 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
})
t.Run("uses session duration from config", func(t *testing.T) {
customMockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
})
service, _, _ := setupJwtService(t, customMockConfig)
user := model.User{
Base: model.Base{ID: "user456"},
}
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
expectedExp := time.Now().Add(30 * time.Minute)
expiration, ok := claims.Expiration()
assert.True(t, ok, "Expiration not found in token")
timeDiff := expectedExp.Sub(expiration).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
})
t.Run("works with Ed25519 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -422,7 +410,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -447,9 +435,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("works with P-256 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createECDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -461,7 +448,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
require.NoError(t, err, "Failed to generate access token with ECDSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -486,9 +473,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("works with RSA-4096 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createRSA4096KeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -500,7 +486,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
require.NoError(t, err, "Failed to generate access token with RSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -526,9 +512,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
}
func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function
ctx := context.Background()
t.Run("succeeds when token type matches expected type", func(t *testing.T) {
// Create a token with the expected type
token := jwt.New()
@@ -539,7 +522,7 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err = validator(ctx, token)
err = validator(t.Context(), token)
assert.NoError(t, err, "Validator should accept token with matching type")
})
@@ -553,7 +536,7 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err = validator(ctx, token)
err = validator(t.Context(), token)
require.Error(t, err, "Validator should reject token with non-matching type")
assert.Contains(t, err.Error(), "invalid token type: expected access-token, got other-token")
})
@@ -566,19 +549,19 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err := validator(ctx, token)
err := validator(t.Context(), token)
require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token type claim")
})
}
func importKey(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, privateKeyRaw any) string {
t.Helper()
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
require.NoError(t, err, "Failed to import private key")
return saveKeyToDatabase(t, db, envConfig, appConfig, privateKey)
return saveKeyToDatabase(t, db, instanceID, envConfig, appConfig, privateKey)
}
// Because generating a RSA-406 key isn't immediate, we pre-compute one
@@ -587,7 +570,7 @@ var (
rsaKeyPrecomputeOnce sync.Once
)
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
t.Helper()
rsaKeyPrecomputeOnce.Do(func() {
@@ -599,10 +582,10 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigS
})
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, rsaKeyPrecomputed)
return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed)
}
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
t.Helper()
// Generate a new P-256 ECDSA key
@@ -610,11 +593,11 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
require.NoError(t, err, "Failed to generate ECDSA key")
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, privateKeyRaw)
return importKey(t, db, instanceID, envConfig, appConfig, privateKeyRaw)
}
// Helper function to create an Ed25519 key and save it as JWK
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
t.Helper()
// Generate a new Ed25519 key pair
@@ -622,5 +605,5 @@ func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
require.NoError(t, err, "Failed to generate Ed25519 key")
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, privateKeyRaw)
return importKey(t, db, instanceID, envConfig, appConfig, privateKeyRaw)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/text/unicode/norm"
@@ -31,11 +32,11 @@ import (
type LdapService struct {
db *gorm.DB
httpClient *http.Client
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
userService *UserService
groupService *UserGroupService
fileStorage storage.FileStorage
clientFactory func() (ldapClient, error)
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
}
type savePicture struct {
@@ -69,7 +70,7 @@ type ldapClient interface {
Close() error
}
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appconfig.AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
service := &LdapService{
db: db,
httpClient: httpClient,
@@ -83,15 +84,13 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
return service
}
func (s *LdapService) createClient() (ldapClient, error) {
dbConfig := s.appConfigService.GetDbConfig()
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
if !dbConfig.LdapEnabled.IsTrue() {
return nil, fmt.Errorf("LDAP is not enabled")
}
// Setup LDAP connection
client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{
client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
}))
if err != nil {
@@ -99,7 +98,7 @@ func (s *LdapService) createClient() (ldapClient, error) {
}
// Bind as service account
err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value)
err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
}
@@ -107,15 +106,20 @@ func (s *LdapService) createClient() (ldapClient, error) {
}
func (s *LdapService) SyncAll(ctx context.Context) error {
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
// Setup LDAP connection
client, err := s.clientFactory()
client, err := s.clientFactory(dbConfig)
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
// First, we fetch all users and group from LDAP, which is our "desired state"
desiredState, err := s.fetchDesiredState(ctx, client)
desiredState, err := s.fetchDesiredState(ctx, client, dbConfig)
if err != nil {
return fmt.Errorf("failed to fetch LDAP state: %w", err)
}
@@ -128,7 +132,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
defer tx.Rollback()
// Reconcile users
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig)
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
@@ -167,7 +171,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
return nil
}
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) {
// Fetch users first so we can use their DNs when resolving group members
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
if err != nil {
@@ -175,7 +179,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
}
// Then fetch groups to complete the desired LDAP state snapshot
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig)
if err != nil {
return ldapDesiredState{}, err
}
@@ -183,7 +187,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
// Apply user admin flags from the desired group membership snapshot.
// This intentionally uses the configured group member attribute rather than
// relying on a user-side reverse-membership attribute such as memberOf.
s.applyAdminGroupMembership(users, groups)
s.applyAdminGroupMembership(users, groups, dbConfig)
return ldapDesiredState{
users: users,
@@ -193,15 +197,14 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
}, nil
}
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
dbConfig := s.appConfigService.GetDbConfig()
if dbConfig.LdapAdminGroupName.Value == "" {
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) {
if dbConfig.LdapAdminGroupName == "" {
return
}
adminUsernames := make(map[string]struct{})
for _, group := range desiredGroups {
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
if group.input.Name != string(dbConfig.LdapAdminGroupName) {
continue
}
@@ -216,21 +219,19 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
}
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
dbConfig := s.appConfigService.GetDbConfig()
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
// Query LDAP for all groups we want to manage
searchAttrs := []string{
dbConfig.LdapAttributeGroupName.Value,
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
dbConfig.LdapAttributeGroupMember.Value,
dbConfig.LdapAttributeGroupName.String(),
dbConfig.LdapAttributeGroupUniqueIdentifier.String(),
dbConfig.LdapAttributeGroupMember.String(),
}
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
dbConfig.LdapBase.String(),
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserGroupSearchFilter.Value,
dbConfig.LdapUserGroupSearchFilter.String(),
searchAttrs,
[]ldap.Control{},
)
@@ -244,21 +245,21 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
for _, value := range result.Entries {
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
// Skip groups without a valid LDAP ID
if ldapID == "" {
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
continue
}
ldapGroupIDs[ldapID] = struct{}{}
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String())
memberUsernames := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String())
if username == "" {
continue
}
@@ -267,8 +268,8 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
LdapID: ldapID,
}
dto.Normalize(&syncGroup)
@@ -290,27 +291,30 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
}
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return nil, nil, nil, fmt.Errorf("error loading app configuration: %w", err)
}
// Query LDAP for all users we want to manage
searchAttrs := []string{
"sn",
"cn",
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
dbConfig.LdapAttributeUserUsername.Value,
dbConfig.LdapAttributeUserEmail.Value,
dbConfig.LdapAttributeUserFirstName.Value,
dbConfig.LdapAttributeUserLastName.Value,
dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
dbConfig.LdapAttributeUserUniqueIdentifier.String(),
dbConfig.LdapAttributeUserUsername.String(),
dbConfig.LdapAttributeUserEmail.String(),
dbConfig.LdapAttributeUserFirstName.String(),
dbConfig.LdapAttributeUserLastName.String(),
dbConfig.LdapAttributeUserProfilePicture.String(),
dbConfig.LdapAttributeUserDisplayName.String(),
}
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
dbConfig.LdapBase.String(),
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserSearchFilter.Value,
dbConfig.LdapUserSearchFilter.String(),
searchAttrs,
[]ldap.Control{},
)
@@ -326,28 +330,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
for _, value := range result.Entries {
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()))
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
usernamesByDN[normalizedDN] = username
}
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String()))
// Skip users without a valid LDAP ID
if ldapID == "" {
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String()))
continue
}
ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()),
// Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false,
@@ -369,16 +373,14 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
desiredUsers = append(desiredUsers, ldapDesiredUser{
ldapID: ldapID,
input: newUser,
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
})
}
return desiredUsers, ldapUserIDs, usernamesByDN, nil
}
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
dbConfig := s.appConfigService.GetDbConfig()
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
// First try the DN cache we built while loading users
username, exists := usernamesByDN[normalizeLDAPDN(member)]
if exists && username != "" {
@@ -386,14 +388,15 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
}
// Then try to extract the username directly from the DN
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
username = getDNProperty(usernameAttr, member)
if username != "" {
return norm.NFC.String(username)
}
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
if _, err := ldap.ParseDN(member); err != nil {
_, err := ldap.ParseDN(member)
if err != nil {
return norm.NFC.String(member)
}
@@ -403,7 +406,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value},
[]string{usernameAttr},
[]ldap.Control{},
)
@@ -413,7 +416,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
return ""
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
username = userResult.Entries[0].GetAttributeValue(usernameAttr)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
return ""
@@ -498,9 +501,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
}
//nolint:gocognit
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) {
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
"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/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -144,8 +145,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
appCfg.LdapAttributeGroupMember = "memberUid"
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
ldapSearchResult(
@@ -237,7 +238,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
tests := []struct {
name string
appConfig *model.AppConfig
appConfig *appconfig.AppConfigModel
groupEntry *ldap.Entry
groupName string
groupLookup string
@@ -255,10 +256,10 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
},
{
name: "configured group name attribute differs from DN RDN",
appConfig: func() *model.AppConfig {
appConfig: func() *appconfig.AppConfigModel {
cfg := defaultTestLDAPAppConfig()
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
cfg.LdapAttributeGroupName = "displayName"
cfg.LdapAdminGroupName = "pocketid.admin"
return cfg
}(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
@@ -308,7 +309,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
}
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
@@ -316,7 +317,7 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
appConfig := NewTestAppConfigService(appConfigModel)
appConfig := appconfig.NewTestAppConfigService(appConfigModel)
groupService := NewUserGroupService(db, appConfig, nil)
userService := NewUserService(
@@ -332,32 +333,32 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
)
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
service.clientFactory = func() (ldapClient, error) {
service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
return client, nil
}
return service, db
}
func defaultTestLDAPAppConfig() *model.AppConfig {
return &model.AppConfig{
RequireUserEmail: model.AppConfigVariable{Value: "false"},
LdapEnabled: model.AppConfigVariable{Value: "true"},
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
func defaultTestLDAPAppConfig() *appconfig.AppConfigModel {
return &appconfig.AppConfigModel{
RequireUserEmail: "false",
LdapEnabled: "true",
LdapBase: "dc=example,dc=com",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapAttributeUserUniqueIdentifier: "entryUUID",
LdapAttributeUserUsername: "uid",
LdapAttributeUserEmail: "mail",
LdapAttributeUserFirstName: "givenName",
LdapAttributeUserLastName: "sn",
LdapAttributeUserDisplayName: "displayName",
LdapAttributeUserProfilePicture: "jpegPhoto",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "entryUUID",
LdapAttributeGroupName: "cn",
LdapAdminGroupName: "admins",
LdapSoftDeleteUsers: "true",
}
}

View File

@@ -16,6 +16,7 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -39,7 +40,7 @@ const (
type OidcService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
previewBuilder oidcClientPreviewBuilder
scimService *ScimService
@@ -54,7 +55,7 @@ type oidcClientPreviewBuilder interface {
func NewOidcService(
db *gorm.DB,
jwtService *JwtService,
appConfigService *AppConfigService,
appConfigService *appconfig.AppConfigService,
previewBuilder oidcClientPreviewBuilder,
scimService *ScimService,
httpClient *http.Client,

View File

@@ -3,11 +3,13 @@ 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"
@@ -20,13 +22,13 @@ import (
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
@@ -38,23 +40,31 @@ func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
_, err = s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return "", fmt.Errorf("error loading app configuration: %w", err)
}
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
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
@@ -151,7 +161,7 @@ func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, use
// Commit
err = tx.Commit().Error
if err != nil {
return "", err
return "", fmt.Errorf("error committing transaction: %w", err)
}
return token, nil
@@ -172,13 +182,18 @@ func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Cont
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err)
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
err = tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
@@ -198,7 +213,11 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
return model.User{}, "", &common.UserDisabledError{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
accessToken, err := s.jwtService.GenerateAccessToken(
oneTimeAccessToken.User,
AuthenticationMethodOneTimePassword,
dbConfig.SessionDuration.AsDurationMinutes(),
)
if err != nil {
return model.User{}, "", err
}
@@ -211,11 +230,16 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
s.auditLogService.Create(
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
ipAddress, userAgent,
oneTimeAccessToken.User.ID, model.AuditLogData{},
tx,
)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
}
return oneTimeAccessToken.User, accessToken, nil

View File

@@ -6,6 +6,7 @@ import (
"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"
@@ -14,8 +15,9 @@ import (
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig())
jwtService := initJwtService(t, db, appConfig, newTestEnvConfig())
appConfig := appconfig.NewTestAppConfigService(nil)
instanceID := newInstanceID(t, db)
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{})
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil, appConfig)

View File

@@ -3,8 +3,10 @@ package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
@@ -17,10 +19,10 @@ import (
type UserGroupService struct {
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
func NewUserGroupService(db *gorm.DB, appConfigService *appconfig.AppConfigService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
}
@@ -63,13 +65,18 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
}
func (s *UserGroupService) Delete(ctx context.Context, id string) error {
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var group model.UserGroup
err := tx.
err = tx.
WithContext(ctx).
Where("id = ?", id).
First(&group).
@@ -79,7 +86,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
}
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return &common.LdapUserGroupUpdateError{}
}
@@ -122,10 +129,9 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
Preload("Users").
Create(&group).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
} else if err != nil {
return model.UserGroup{}, err
}
@@ -161,8 +167,13 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err
}
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.UserGroup{}, fmt.Errorf("error loading app configuration: %w", err)
}
// Disallow updating the group if it is an LDAP group and LDAP is enabled
if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
if !isLdapSync && group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
}
@@ -216,7 +227,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
// Fetch the users based on the userIds
var users []model.User
if len(userIds) > 0 {
err := tx.
err = tx.
WithContext(ctx).
Where("id IN (?)", userIds).
Find(&users).

View File

@@ -13,16 +13,17 @@ import (
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"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"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
@@ -31,14 +32,14 @@ type UserService struct {
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
appConfigService *appconfig.AppConfigService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
@@ -204,9 +205,13 @@ func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDe
}
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error {
var user model.User
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
err := tx.
var user model.User
err = tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -217,7 +222,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
}
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{}
}
@@ -253,7 +258,12 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
}
func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil {
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
}
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
@@ -285,7 +295,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
user.LdapID = &input.LdapID
}
err := tx.WithContext(ctx).Create(&user).Error
err = tx.WithContext(ctx).Create(&user).Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync {
@@ -304,12 +314,14 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
err = s.applyDefaultGroups(ctx, &user, tx)
if err != nil {
return model.User{}, err
}
}
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
err = s.applyDefaultCustomClaims(ctx, &user, tx)
if err != nil {
return model.User{}, err
}
}
@@ -322,12 +334,15 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
}
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
var groupIDs []string
v := config.SignupDefaultUserGroupIDs.Value
v := cfg.SignupDefaultUserGroupIDs
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &groupIDs)
err = json.Unmarshal([]byte(v), &groupIDs)
if err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
}
@@ -354,12 +369,15 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
var claims []dto.CustomClaimCreateDto
v := config.SignupDefaultCustomClaims.Value
v := cfg.SignupDefaultCustomClaims
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims)
err = json.Unmarshal([]byte(v), &claims)
if err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
@@ -394,12 +412,17 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser
}
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil {
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
}
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
var user model.User
err := tx.
err = tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -410,8 +433,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
}
// Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
// Restricted update: Only locale can be changed when:
@@ -429,7 +452,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
// Email has changed, reset email verification status
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
user.EmailVerified = cfg.EmailsVerified.IsTrue()
}
user.Email = updatedUser.Email

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
"net/netip"
"net/url"
"strings"
@@ -56,7 +57,17 @@ func IsTailscaleIP(ip net.IP) bool {
}
func IsPrivateIP(ip net.IP) bool {
return IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip)
if IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip) {
return true
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
}
func IsURLPrivate(ctx context.Context, u *url.URL) (bool, error) {

View File

@@ -110,12 +110,19 @@ func TestIsPrivateIP(t *testing.T) {
ip string
expected bool
}{
{"127.0.0.1", true}, // localhost
{"192.168.1.1", true}, // private LAN
{"100.64.0.1", true}, // Tailscale
{"fd00::1", true}, // local IPv6
{"8.8.8.8", false}, // public IPv4
{"2001:4860:4860::8888", false}, // public IPv6
{"127.0.0.1", true}, // localhost
{"192.168.1.1", true}, // private LAN
{"100.64.0.1", true}, // Tailscale
{"169.254.169.254", true}, // IPv4 link-local
{"169.254.170.2", true}, // IPv4 link-local
{"::ffff:169.254.169.254", true}, // IPv4-mapped link-local
{"fe80::1", true}, // IPv6 link-local
{"ff02::1", true}, // IPv6 link-local multicast
{"0.0.0.0", true}, // IPv4 unspecified
{"::", true}, // IPv6 unspecified
{"fd00::1", true}, // private IPv6
{"8.8.8.8", false}, // public IPv4
{"2001:4860:4860::8888", false}, // public IPv6
}
for _, tt := range tests {
@@ -231,6 +238,36 @@ func TestIsURLPrivate(t *testing.T) {
expectPriv: true,
expectError: false,
},
{
name: "IPv4 link-local metadata IP",
urlStr: "http://169.254.169.254",
expectPriv: true,
expectError: false,
},
{
name: "IPv4-mapped link-local metadata IP",
urlStr: "http://[::ffff:169.254.169.254]",
expectPriv: true,
expectError: false,
},
{
name: "IPv6 link-local IP",
urlStr: "http://[fe80::1]",
expectPriv: true,
expectError: false,
},
{
name: "IPv4 unspecified IP",
urlStr: "http://0.0.0.0",
expectPriv: true,
expectError: false,
},
{
name: "IPv6 unspecified IP",
urlStr: "http://[::]",
expectPriv: true,
expectError: false,
},
{
name: "public IP - Google DNS",
urlStr: "http://8.8.8.8",

View File

@@ -1,3 +1,5 @@
//go:build unit
// This file is only imported by unit tests
package testing
@@ -18,25 +20,27 @@ import (
const testActorHostPSK = "pocket-id-test-actor-host-psk-32bytes"
// NewActorHostForTest starts a single-host Francis cluster backed by the in-memory provider, runs it, and waits until it is ready to serve invocations
// Pass built-in actors with local.WithBuiltInActor; the host is stopped when the test ends
// The register callback, if not nil, runs after the host is created but before it starts, so callers can register actors with host.RegisterActor/host.RegisterBuiltInActor (must be called before the host is running)
// The host is stopped when the test ends
// The in-memory provider keeps no state on disk, so the test never touches a real database
func NewActorHostForTest(t *testing.T, opts ...local.HostOption) *local.Host {
func NewActorHostForTest(t *testing.T, register func(t *testing.T, h *local.Host)) *local.Host {
t.Helper()
// The defaults come first so callers can register their built-in actors (and override any default) through opts
hostOpts := append(
[]local.HostOption{
local.WithAddress(freeLoopbackAddr(t)),
local.WithRuntimePSKs([]byte(testActorHostPSK)),
local.WithStandaloneMemoryProvider(standalone.StandaloneMemoryOptions{}),
local.WithShutdownGracePeriod(time.Second),
},
opts...,
)
hostOpts := []local.HostOption{
local.WithAddress(freeLoopbackAddr(t)),
local.WithRuntimePSKs([]byte(testActorHostPSK)),
local.WithStandaloneMemoryProvider(standalone.StandaloneMemoryOptions{}),
local.WithShutdownGracePeriod(time.Second),
}
h, err := local.NewHost(hostOpts...)
require.NoError(t, err)
// Register built-in actors before the host starts
if register != nil {
register(t, h)
}
// Run the host in the background and stop it when the test ends
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)

View File

@@ -1,8 +1,13 @@
//go:build unit
// This file is only imported by unit tests
package testing
import (
"errors"
"log/slog"
"path/filepath"
"testing"
"time"
@@ -11,6 +16,7 @@ import (
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
sqlitekit "github.com/italypaleale/go-sql-utils/sqlite"
"github.com/libtnb/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@@ -28,28 +34,43 @@ func init() {
// NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database.
// Each database connection is unique for the test.
// All migrations are automatically performed.
// Note: the in-memory database is limited to a single connection, so it cannot be used to test concurrent access: use NewConcurrentDatabaseForTest for that.
func NewDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
db := openInMemoryTestDB(t)
runMigrations(t, db, 0, nil)
return db
}
// NewDatabaseForTestWithMigrationSeed behaves like NewDatabaseForTest, but pauses the migrations right after the migration whose version is stopAfterVersion has been applied.
// It then invokes seed, so the test can insert data into the intermediate schema, before applying the remaining migrations.
// This is meant to test data migrations, which operate on data that already exists in the database.
func NewDatabaseForTestWithMigrationSeed(t *testing.T, stopAfterVersion uint, seed func(t *testing.T, db *gorm.DB)) *gorm.DB {
t.Helper()
db := openInMemoryTestDB(t)
runMigrations(t, db, stopAfterVersion, seed)
return db
}
// NewConcurrentDatabaseForTest returns a new instance of GORM connected to a temporary file-based SQLite database
// Unlike NewDatabaseForTest, which forces a single connection to an in-memory database, this one can be used to test concurrent access to the database.
// All migrations are automatically performed.
func NewConcurrentDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
db := openFileTestDB(t)
runMigrations(t, db, 0, nil)
return db
}
// openInMemoryTestDB opens a GORM instance backed by an in-memory SQLite database, unique to the test.
func openInMemoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory"), newTestGormConfig(t))
require.NoError(t, err, "Failed to connect to test database")
sqlDB, err := db.DB()
@@ -59,6 +80,31 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
sqlDB.SetMaxOpenConns(1)
return db
}
// openFileTestDB opens a GORM instance backed by a temporary file-based SQLite database, configured like the production database
func openFileTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
connString, _, _, err := sqlitekit.ParseConnectionString(dbPath, slog.Default())
require.NoError(t, err, "Failed to parse connection string")
db, err := gorm.Open(sqlite.Open(connString), newTestGormConfig(t))
require.NoError(t, err, "Failed to connect to test database")
return db
}
// runMigrations applies the embedded SQLite migrations to db.
// If seed is not nil, migrations are first applied up to and including stopAfterVersion, then seed is invoked so the test can insert data into the intermediate schema, before the remaining migrations are applied.
func runMigrations(t *testing.T, db *gorm.DB, stopAfterVersion uint, seed func(t *testing.T, db *gorm.DB)) {
t.Helper()
sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB")
// Perform migrations with the embedded migrations
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{
NoTxWrap: true,
@@ -68,12 +114,40 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
require.NoError(t, err, "Failed to create embedded migration source")
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
require.NoError(t, err, "Failed to create migration instance")
// If the test wants to seed data partway through the migrations, apply migrations up to and including stopAfterVersion first, then let it seed
if seed != nil {
err = m.Migrate(stopAfterVersion)
require.NoErrorf(t, err, "Failed to perform migrations up to version %d", stopAfterVersion)
seed(t, db)
}
// Apply all the remaining migrations
// ErrNoChange means we were already at the latest version, which is not an error here
err = m.Up()
require.NoError(t, err, "Failed to perform migrations")
if !errors.Is(err, migrate.ErrNoChange) {
require.NoError(t, err, "Failed to perform migrations")
}
_, err = sqlDB.Exec("PRAGMA foreign_keys = OFF;")
require.NoError(t, err, "Failed to disable foreign keys")
}
return db
// newTestGormConfig returns the GORM configuration shared by the test databases, wiring the logger to the test's output.
func newTestGormConfig(t *testing.T) *gorm.Config {
t.Helper()
return &gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
}
}
// Implements gorm's logger.Writer interface

View File

@@ -1,3 +1,5 @@
//go:build unit
// This file is only imported by unit tests
package testing

View File

@@ -16,8 +16,8 @@ type handler struct {
appConfig AppConfigProvider
}
func newHandler(service *Service, appConfig AppConfigProvider) *handler {
return &handler{service: service, appConfig: appConfig}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
func (h *handler) beginRegistration(c *gin.Context) {

View File

@@ -8,18 +8,19 @@ import (
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
type TokenService interface {
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
VerifyAccessToken(tokenString string) (jwt.Token, error)
GetAuthenticationMethod(token jwt.Token) (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)
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog
}
type AppConfigProvider interface {
@@ -30,9 +31,8 @@ type Dependencies struct {
DB *gorm.DB
AppURL string
Signer TokenService
AuditLog AuditLogger
AppConfig AppConfigProvider
Signer TokenService
AuditLog AuditLogger
}
type Module struct {
@@ -48,7 +48,7 @@ func New(deps Dependencies) (*Module, error) {
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
handler: newHandler(service),
}, nil
}

View File

@@ -13,6 +13,7 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -24,11 +25,10 @@ import (
const authenticationMethodPhishingResistant = "phr"
type Service struct {
db *gorm.DB
webAuthn *gowebauthn.WebAuthn
signer TokenService
auditLog AuditLogger
appConfig AppConfigProvider
db *gorm.DB
webAuthn *gowebauthn.WebAuthn
signer TokenService
auditLog AuditLogger
}
func newService(deps Dependencies) (*Service, error) {
@@ -57,11 +57,10 @@ func newService(deps Dependencies) (*Service, error) {
}
return &Service{
db: deps.DB,
webAuthn: wa,
signer: deps.Signer,
auditLog: deps.AuditLog,
appConfig: deps.AppConfig,
db: deps.DB,
webAuthn: wa,
signer: deps.Signer,
auditLog: deps.AuditLog,
}, nil
}
@@ -228,6 +227,11 @@ func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOp
}
func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err)
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -235,7 +239,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA
// Load & delete the session row
var storedSession WebauthnSession
err := tx.
err = tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID).
@@ -275,7 +279,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA
return model.User{}, "", err
}
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx)
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig)
err = tx.Commit().Error
if err != nil {

View File

@@ -1,7 +1,7 @@
ALTER TABLE public.audit_logs
ALTER TABLE audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id);
FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE public.oidc_authorization_codes
ALTER TABLE oidc_authorization_codes
DROP CONSTRAINT IF EXISTS oidc_authorization_codes_client_fk;

View File

@@ -1,8 +1,8 @@
ALTER TABLE public.audit_logs
ALTER TABLE audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id) ON DELETE CASCADE;
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE public.oidc_authorization_codes
ALTER TABLE oidc_authorization_codes
ADD CONSTRAINT oidc_authorization_codes_client_fk
FOREIGN KEY (client_id) REFERENCES public.oidc_clients (id) ON DELETE CASCADE;
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;

View File

@@ -0,0 +1,8 @@
-- Move the instance ID back into the standalone app config table
INSERT INTO app_config_variables ("key", "value")
SELECT 'instanceId', "value"
FROM kv
WHERE "key" = 'instance_id' AND "value" IS NOT NULL
ON CONFLICT ("key") DO NOTHING;
DELETE FROM kv WHERE "key" = 'instance_id';

View File

@@ -0,0 +1,8 @@
-- Move the instance ID out of the standalone app config table and into the "kv" table
INSERT INTO kv ("key", "value")
SELECT 'instance_id', "value"
FROM app_config_variables
WHERE "key" = 'instanceId'
ON CONFLICT ("key") DO NOTHING;
DELETE FROM app_config_variables WHERE "key" = 'instanceId';

View File

@@ -0,0 +1,16 @@
-- Recreate the standalone config table with the same schema it had before it was frozen
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);
-- Populate it from the frozen JSON document stored in the "kv" table
-- json_each expands the JSON object back into one row per key/value pair.
INSERT INTO app_config_variables (key, value)
SELECT je.key, je.value
FROM kv, json_each_text(kv."value"::json) AS je(key, value)
WHERE kv."key" = 'config_migrated';
-- Remove the frozen config from the "kv" table
DELETE FROM kv WHERE "key" = 'config_migrated';

View File

@@ -0,0 +1,12 @@
-- Freeze the app configuration
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
--
-- json_object_agg aggregates all rows into a JSON object
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
INSERT INTO kv ("key", "value")
SELECT 'config_migrated', json_object_agg("key", "value")::text
FROM app_config_variables
HAVING count(*) > 0;
-- Drop the now-frozen standalone config table
DROP TABLE app_config_variables;

View File

@@ -0,0 +1,14 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Move the instance ID back into the standalone app config table
INSERT INTO app_config_variables ("key", "value")
SELECT 'instanceId', "value"
FROM kv
WHERE "key" = 'instance_id' AND "value" IS NOT NULL
ON CONFLICT ("key") DO NOTHING;
DELETE FROM kv WHERE "key" = 'instance_id';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,14 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Move the instance ID out of the standalone app config table and into the "kv" table
INSERT INTO kv ("key", "value")
SELECT 'instance_id', "value"
FROM app_config_variables
WHERE "key" = 'instanceId'
ON CONFLICT ("key") DO NOTHING;
DELETE FROM app_config_variables WHERE "key" = 'instanceId';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,22 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Recreate the standalone config table with the same schema it had before it was frozen
CREATE TABLE app_config_variables
(
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);
-- Populate it from the frozen JSON document stored in the "kv" table
-- json_each expands the JSON object back into one row per key/value pair.
INSERT INTO app_config_variables ("key", "value")
SELECT je.key, je.value
FROM kv, json_each(kv."value") AS je
WHERE kv."key" = 'config_migrated';
-- Remove the frozen config from the "kv" table
DELETE FROM kv WHERE "key" = 'config_migrated';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,18 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Freeze the app configuration
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
--
-- json_group_object aggregates all rows into a JSON object
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
INSERT INTO kv ("key", "value")
SELECT 'config_migrated', json_group_object("key", "value")
FROM app_config_variables
HAVING count(*) > 0;
-- Drop the now-frozen standalone config table
DROP TABLE app_config_variables;
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.6.5",
"react-email": "6.6.6",
"tsx": "^4.22.2"
}
}

View File

@@ -183,7 +183,7 @@
"email_tls_option": "Opció TLS del correu",
"skip_certificate_verification": "Omet la verificació del certificat",
"skip_consent": "Omet la pantalla de consentiment",
"skip_consent_description": "No demanis als usuaris que aprovin aquest client ni els escops que sol·licita. Només activa-ho per a clients de primera part de confiança.",
"skip_consent_description": "No demanis als usuaris que aprovin aquest client ni els àmbits que sol·licita. Només activa-ho per a clients de confiança.",
"this_can_be_useful_for_selfsigned_certificates": "Això pot ser útil per certificats autosignats.",
"enabled_emails": "Correus habilitats",
"email_login_notification": "Notificació d'inici de sessió per correu",
@@ -287,8 +287,8 @@
"proof_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Proof Key for Code Exchange és una característica de seguretat per prevenir atacs CSRF i la interceptació del codi d'autorització.",
"requires_reauthentication": "Requereix re-autenticació",
"requires_users_to_authenticate_again_on_each_authorization": "Requereix que els usuaris s'autentiquin de nou en cada autorització, encara que ja estiguin connectats",
"par": "Par",
"requires_pushed_authorization_requests": "Requereix sol·licituds d'autorització enviades",
"par": "PAR",
"requires_pushed_authorization_requests": "Requereix sol·licituds d'autorització \"push\"",
"requires_pushed_authorization_requests_description": "Exigeix als clients que utilitzin el punt final PAR per pre-registrar els paràmetres d'autorització abans d'iniciar el flux.",
"name_logo": "Logotip de {name}",
"change_logo": "Canvia el logotip",
@@ -537,11 +537,11 @@
"create_api": "Crea API",
"create_a_new_api_description": "Crea una nova API a la qual els clients puguin sol·licitar tokens d'accés.",
"add_api": "Afegir API",
"manage_apis": "Gestiona les API",
"manage_apis": "Gestiona les APIs",
"api_resource": "Recurs",
"api_resource_description": "Un URI únic que identifica aquest recurs de l'API. Els clients el sol·liciten amb el paràmetre resource. No es pot canviar més endavant.",
"api_resource_description": "Un URI únic que identifica aquest recurs de l'API. Els clients el sol·liciten amb el paràmetre \"resource\". No es pot canviar més endavant.",
"api_permissions": "Permisos",
"api_permissions_description": "Els permisos (àmbit) que els clients poden sol·licitar per a aquesta API.",
"api_permissions_description": "Els permisos (àmbits) que els clients poden sol·licitar per a aquesta API.",
"api_permission_key": "Permís",
"add_permission": "Afegeix permís",
"type": "Tipus",
@@ -553,15 +553,15 @@
"api_access": "Accés a l'API",
"api_access_description": "Seleccioneu quins permisos de l'API pot sol·licitar aquest client en nom dels usuaris (accés delegat per l'usuari) i per a si mateix mitjançant l'atorgament amb credencials de client (accés de client).",
"api_access_updated_successfully": "L'accés a l'API s'ha actualitzat correctament",
"no_apis_defined_yet": "Encara no s'han definit cap API. Les API permeten als clients sol·licitar tokens d'accés per a recursos i permisos específics.",
"no_apis_defined_yet": "Encara no s'ha definit cap API. Les API permeten als clients sol·licitar tokens d'accés per a recursos i permisos específics.",
"access_an_api_on_your_behalf": "Accedir a una API en nom teu",
"api_name": "Nom de l'API",
"access": "Accés",
"user_delegated_access": "Accés delegat a l'usuari",
"user_delegated_access": "Accés per usuari delegat",
"client_access": "Accés del client (M2M)",
"client_access_unavailable_for_public_clients": "Els clients públics no poden utilitzar l'atorgament de credencials de client, per tant l'accés del client no està disponible.",
"permissions_granted_count": "{granted} / {total} permisos concedits",
"select_the_permissions_this_client_may_request": "Seleccioneu els permisos que aquest client pot sol·licitar en nom de l'usuari iniciat (accés delegat per usuari) i per a si mateix sense cap usuari mitjançant l'atorgament amb credencials de client (accés de client).",
"select_the_permissions_this_client_may_request": "Seleccioneu els permisos que aquest client pot sol·licitar en nom de l'usuari que ha iniciat sessió (accés delegat per l'usuari) i per a si mateix sense cap usuari mitjançant l'atorgament amb credencials (accés de client).",
"i_have_a_longer_code": "Tinc un codi més llarg",
"pkce_supported_client_title": "Aquest client admet PKCE.",
"pkce_supported_client_description": "Aquest client admet Proof Key for Code Exchange (PKCE). PKCE és una característica de seguretat que ajuda a protegir contra certs atacs durant el procés d'autorització OAuth 2.0. Es recomana habilitar-la."

View File

@@ -112,7 +112,7 @@
"account_settings": "Configuración de la cuenta",
"passkey_missing": "Passkey no encontrada",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Por favor, añade una clave de acceso o passkey para evitar que pierdas el acceso a tu cuenta.",
"single_passkey_configured": "Clave única configurada",
"single_passkey_configured": "Solo hay una Clave de acceso configurada",
"it_is_recommended_to_add_more_than_one_passkey": "Se recomienda añadir más de una clave de acceso para evitar perder el acceso a tu cuenta.",
"account_details": "Detalles de la cuenta",
"passkeys": "Claves de acceso",
@@ -451,7 +451,7 @@
"my_apps": "Mis aplicaciones",
"no_apps_available": "No hay aplicaciones disponibles",
"contact_your_administrator_for_app_access": "Ponte en contacto con tu administrador para obtener acceso a las aplicaciones.",
"launch": "Lanzamiento",
"launch": "Iniciar",
"client_launch_url": "URL de inicio del cliente",
"client_launch_url_description": "La URL que se abrirá cuando un usuario inicie la aplicación desde la página Mis aplicaciones.",
"client_name_description": "El nombre del cliente que aparece en la interfaz de usuario de Pocket ID.",
@@ -466,7 +466,7 @@
"generated": "Generado",
"administration": "Administración",
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos.",
"display_name_attribute": "Atributo de nombre para mostrar",
"display_name_attribute": "Atributo de nombre a mostrar",
"display_name": "Nombre para mostrar",
"configure_application_images": "Configurar imágenes de aplicaciones",
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
@@ -482,7 +482,7 @@
"reauthentication": "Reautenticación",
"clear_filters": "Borrar filtros",
"default_profile_picture": "Imagen de perfil predeterminada",
"light": "Luz",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"signup_token_user_groups_description": "Asignad automáticamente estos grupos a los usuarios que se registren utilizando este token.",

View File

@@ -455,6 +455,8 @@
"client_launch_url": "URL de lancement du client",
"client_launch_url_description": "L'URL qui s'ouvrira quand quelqu'un lancera l'appli depuis la page Mes applis.",
"client_name_description": "Le nom du client qui apparaît dans l'interface utilisateur Pocket ID.",
"client_description": "Description",
"client_description_description": "Une description facultative du client qui s'affiche dans l'interface utilisateur de Pocket ID.",
"revoke_access": "Supprimer l'accès",
"revoke_access_description": "Supprimer l'accès à <b>{clientName}</b>. <b>{clientName}</b> ne pourra plus accéder aux infos de votre compte.",
"revoke_access_successful": "L'accès à {clientName} a été supprimé.",
@@ -533,7 +535,7 @@
"replay_protection_description": "Si cette option est activée, le jeton fourni ne peut être utilisé qu'une seule fois. Si ton fournisseur utilise le même jeton plusieurs fois, tu devras peut-être désactiver cette option.",
"apis": "API",
"create_api": "Créer une API",
"create_a_new_api_description": "Crée une nouvelle API qui permette aux clients de demander des jetons d'accès.",
"create_a_new_api_description": "Crée une nouvelle API permettant aux clients de demander des jetons d'accès. Tu trouveras plus d'infos sur les API dans la <link href='https://pocket-id.org/docs/guides/apis'>documentation</link>.",
"add_api": "Ajouter une API",
"manage_apis": "Gérer les API",
"api_resource": "Ressource",

View File

@@ -105,7 +105,7 @@
"ip_address": "IP 주소",
"device": "기기",
"client": "클라이언트",
"actor": "배우",
"actor": "작업자",
"unknown": "알 수 없음",
"account_details_updated_successfully": "계정 정보가 성공적으로 변경되었습니다",
"profile_picture_updated_successfully": "프로필 사진이 성공적으로 변경되었습니다. 변경 적용까지 몇 분 정도 걸릴 수 있습니다.",
@@ -172,7 +172,7 @@
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "테스트 이메일을 전송하기 전에 변경 내용을 저장해야 합니다. 지금 저장하시겠습니까?",
"save_and_send": "저장하고 전송하기",
"test_email_sent_successfully": "테스트 이메일이 성공적으로 귀하의 이메일 주소에 전송되었습니다.",
"failed_to_send_test_email": "테스트 이메일 전송에 실패했니다. 자세한 내용을 서버 로그를 확인하세요.",
"failed_to_send_test_email": "테스트 이메일 전송에 실패했니다. 자세한 내용을 서버 로그를 확인하세요.",
"smtp_configuration": "SMTP 구성",
"smtp_host": "SMTP 호스트",
"smtp_port": "SMTP 포트",
@@ -288,8 +288,8 @@
"requires_reauthentication": "재인증 요구",
"requires_users_to_authenticate_again_on_each_authorization": "사용자가 이미 로그인한 상태에서도 승인할 때마다 다시 인증을 요구합니다.",
"par": "PAR",
"requires_pushed_authorization_requests": "푸시 방식의 인증 요청이 필요합니다",
"requires_pushed_authorization_requests_description": "클라이언트 흐름을 시작하기 전에 PAR 엔드포인트를 사용하여 인증 매개변수를 사전 등록해야 합니다.",
"requires_pushed_authorization_requests": "푸시 방식의 인증 요청 요구",
"requires_pushed_authorization_requests_description": "클라이언트 흐름을 시작하기 전에 PAR 엔드포인트를 사용하여 인증 매개변수를 사전 등록하도록 요구합니다.",
"name_logo": "{name} 로고",
"change_logo": "로고 변경",
"upload_logo": "로고 업로드",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "2.10.0",
"version": "2.11.0",
"private": true,
"type": "module",
"scripts": {
@@ -15,7 +15,7 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.41.1",

View File

@@ -106,7 +106,7 @@
</div>
{:else}
<div
class="flex h-screen items-center justify-center bg-cover bg-center text-center"
class="flex min-h-dvh items-center justify-center bg-cover bg-center text-center"
style="background-image: url({cachedBackgroundImage.getUrl()});"
>
<Card.Root

View File

@@ -172,6 +172,7 @@
id="signup-token-list"
withoutSearch={true}
fetchCallback={userService.listSignupTokens}
defaultSort={{ column: 'createdAt', direction: 'asc' }}
bind:this={tableRef}
{columns}
{actions}

View File

@@ -22,7 +22,7 @@
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
window.location.hash = newValue;
history.replaceState(history.state, "",location.pathname + location.search + `#${newValue}`);
}
}
</script>

View File

@@ -22,6 +22,15 @@ export type OidcClientCredentials = {
federatedIdentities: OidcClientFederatedIdentity[];
};
export type OidcDiscoveryConfiguration = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
end_session_endpoint: string;
jwks_uri: string;
};
export type OidcClient = OidcClientMetaData & {
callbackURLs: string[];
logoutCallbackURLs: string[];

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
@@ -47,13 +46,13 @@
const backNavigation = backNavigate('/settings/admin/oidc-clients');
const setupDetails = $state({
[m.issuer_url()]: `https://${page.url.host}`,
[m.authorization_url()]: `https://${page.url.host}/authorize`,
[m.oidc_discovery_url()]: `https://${page.url.host}/.well-known/openid-configuration`,
[m.token_url()]: `https://${page.url.host}/api/oidc/token`,
[m.userinfo_url()]: `https://${page.url.host}/api/oidc/userinfo`,
[m.logout_url()]: `https://${page.url.host}/api/oidc/end-session`,
[m.certificate_url()]: `https://${page.url.host}/.well-known/jwks.json`,
[m.issuer_url()]: data.oidcConfiguration.issuer,
[m.authorization_url()]: data.oidcConfiguration.authorization_endpoint,
[m.oidc_discovery_url()]: `${data.oidcConfiguration.issuer}/.well-known/openid-configuration`,
[m.token_url()]: data.oidcConfiguration.token_endpoint,
[m.userinfo_url()]: data.oidcConfiguration.userinfo_endpoint,
[m.logout_url()]: data.oidcConfiguration.end_session_endpoint,
[m.certificate_url()]: data.oidcConfiguration.jwks_uri,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled(),
[m.requires_pushed_authorization_requests()]: client.requiresPushedAuthorizationRequests

View File

@@ -1,16 +1,28 @@
import OidcService from '$lib/services/oidc-service';
import type { OidcDiscoveryConfiguration } from '$lib/types/oidc.type';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
export const load: PageLoad = async ({ fetch, params }) => {
const oidcService = new OidcService();
const client = await oidcService.getClient(params.id);
const scimServiceProvider = await oidcService
const clientPromise = oidcService.getClient(params.id);
const scimServiceProviderPromise = oidcService
.getScimResourceProvider(params.id)
.then((p) => p)
.catch(() => undefined);
const oidcConfigurationPromise = fetch('/.well-known/openid-configuration').then(
(response) => response.json() as Promise<OidcDiscoveryConfiguration>
);
const [client, scimServiceProvider, oidcConfiguration] = await Promise.all([
clientPromise,
scimServiceProviderPromise,
oidcConfigurationPromise
]);
return {
client,
scimServiceProvider
scimServiceProvider,
oidcConfiguration
};
};

140
pnpm-lock.yaml generated
View File

@@ -57,8 +57,8 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.17)
react-email:
specifier: 6.6.5
version: 6.6.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: 6.6.6
version: 6.6.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
tsx:
specifier: ^4.22.2
version: 4.23.0
@@ -69,8 +69,8 @@ importers:
specifier: ^1.9.1
version: 1.9.1
'@opentelemetry/exporter-trace-otlp-http':
specifier: ^0.219.0
version: 0.219.0(@opentelemetry/api@1.9.1)
specifier: ^0.220.0
version: 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources':
specifier: ^2.8.0
version: 2.9.0(@opentelemetry/api@1.9.1)
@@ -786,74 +786,56 @@ packages:
cpu: [x64]
os: [win32]
'@opentelemetry/api-logs@0.219.0':
resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==}
'@opentelemetry/api-logs@0.220.0':
resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/core@2.8.0':
resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.9.0':
resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-trace-otlp-http@0.219.0':
resolution: {integrity: sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==}
'@opentelemetry/exporter-trace-otlp-http@0.220.0':
resolution: {integrity: sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.219.0':
resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==}
'@opentelemetry/otlp-exporter-base@0.220.0':
resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.219.0':
resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==}
'@opentelemetry/otlp-transformer@0.220.0':
resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/resources@2.8.0':
resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/resources@2.9.0':
resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.219.0':
resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==}
'@opentelemetry/sdk-logs@0.220.0':
resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.8.0':
resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==}
'@opentelemetry/sdk-metrics@2.9.0':
resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.8.0':
resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.9.0':
resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2258,8 +2240,8 @@ packages:
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
engines: {node: '>=18'}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lz-string@1.5.0:
@@ -2592,8 +2574,8 @@ packages:
peerDependencies:
react: ^19.2.7
react-email@6.6.5:
resolution: {integrity: sha512-IO2NXS17K5xEn9v8QVt28g8Nl6D4gmaKZZc61tOGiZla4X2F+veWjuSKCJC7HDIuEtZXF27chHo9sE6Mtey6tQ==}
react-email@6.6.6:
resolution: {integrity: sha512-4eYmISIQbmEzibkl8pW+ZQHIFMwUVqZQRpTDYj6Ow7kk/gpUjtA95ggxoXji8pRq/y76tOhe7D3vNUJkZWO58g==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
@@ -2899,8 +2881,8 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
type-fest@5.7.0:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
type-fest@5.8.0:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
typebox@1.3.3:
@@ -3139,7 +3121,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/parser': 7.27.0
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3
@@ -3528,52 +3510,41 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.10':
optional: true
'@opentelemetry/api-logs@0.219.0':
'@opentelemetry/api-logs@0.220.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/exporter-trace-otlp-http@0.219.0(@opentelemetry/api@1.9.1)':
'@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)':
'@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)':
'@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.219.0
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/api-logs': 0.220.0
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
@@ -3581,26 +3552,19 @@ snapshots:
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)':
'@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.219.0
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/api-logs': 0.220.0
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)':
'@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
@@ -4399,7 +4363,7 @@ snapshots:
dot-prop@10.1.0:
dependencies:
type-fest: 5.7.0
type-fest: 5.8.0
dotenv@17.4.2: {}
@@ -4905,7 +4869,7 @@ snapshots:
is-unicode-supported: 2.1.0
yoctocolors: 2.1.2
lru-cache@11.5.1: {}
lru-cache@11.5.2: {}
lz-string@1.5.0: {}
@@ -5042,7 +5006,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
lru-cache: 11.5.2
minipass: 7.1.3
pathe@2.0.3: {}
@@ -5134,7 +5098,7 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-email@6.6.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
react-email@6.6.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/parser': 7.27.0
'@babel/traverse': 7.27.0
@@ -5535,7 +5499,7 @@ snapshots:
type-fest@2.19.0:
optional: true
type-fest@5.7.0:
type-fest@5.8.0:
dependencies:
tagged-tag: 1.0.0

View File

@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260708120000,
"version": 20260708130000,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
"tables": {
"apis": [
@@ -66,13 +66,11 @@
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"app_config_variables": [
{
"key": "instanceId",
"value": "test-instance-id"
}
],
"kv": [
{
"key": "instance_id",
"value": "test-instance-id"
},
{
"key": "jwt_private_key.json",
"value": "7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="

View File

@@ -89,6 +89,27 @@ test('Edit OIDC client', async ({ page }) => {
.then((res) => expect.soft(res.status()).toBe(200));
});
test('Displays OIDC client endpoints from discovery configuration', async ({ page }) => {
const oidcConfiguration = {
issuer: 'https://id.example.com',
authorization_endpoint: 'https://id.example.com/authorize',
token_endpoint: 'http://pocket-id:1411/api/oidc/token',
userinfo_endpoint: 'http://pocket-id:1411/api/oidc/userinfo',
end_session_endpoint: 'https://id.example.com/api/oidc/end-session',
jwks_uri: 'http://pocket-id:1411/.well-known/jwks.json'
};
await page.route('**/.well-known/openid-configuration', async (route) => {
await route.fulfill({ json: oidcConfiguration });
});
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
await page.getByRole('button', { name: 'Show more details' }).click();
await expect(page.getByText(oidcConfiguration.token_endpoint, { exact: true })).toBeVisible();
await expect(page.getByText(oidcConfiguration.userinfo_endpoint, { exact: true })).toBeVisible();
await expect(page.getByText(oidcConfiguration.jwks_uri, { exact: true })).toBeVisible();
});
test('Create new OIDC client secret', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);