mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525946e94e | ||
|
|
bb03660bd7 | ||
|
|
9714296efb | ||
|
|
a3f27ec2ec | ||
|
|
316cf47ceb | ||
|
|
ef9ed8de32 | ||
|
|
217bf75b7c | ||
|
|
77398a558d | ||
|
|
187cd8ddcd | ||
|
|
d9ead47d19 | ||
|
|
7348bdac50 | ||
|
|
69a6e22c7c | ||
|
|
cfda5f693b | ||
|
|
ff8e34cccf | ||
|
|
ff5e565eeb |
@@ -15,4 +15,4 @@ ENCRYPTION_KEY=
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
PGID=1000
|
||||
|
||||
6
.github/workflows/backend-linter.yml
vendored
6
.github/workflows/backend-linter.yml
vendored
@@ -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' }}
|
||||
|
||||
6
.github/workflows/build-next.yml
vendored
6
.github/workflows/build-next.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/unit-tests.yml
vendored
2
.github/workflows/unit-tests.yml
vendored
@@ -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:
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"go.buildTags": "e2etest",
|
||||
"go.buildTags": "e2etest unit",
|
||||
"prettier.documentSelectors": ["**/*.svelte"]
|
||||
}
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -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.
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 don’t need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, you’ll 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.
|
||||
|
||||
@@ -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
11
backend/Makefile
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,7 +44,7 @@ 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, 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)
|
||||
@@ -63,7 +63,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 +88,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,
|
||||
|
||||
@@ -12,8 +12,8 @@ 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/instanceid"
|
||||
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 {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
50
backend/internal/instanceid/instanceid.go
Normal file
50
backend/internal/instanceid/instanceid.go
Normal 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
|
||||
}
|
||||
190
backend/internal/instanceid/instanceid_test.go
Normal file
190
backend/internal/instanceid/instanceid_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/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"
|
||||
@@ -35,7 +36,10 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
||||
instanceID, err := instanceid.Load(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
|
||||
@@ -23,15 +23,16 @@ 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()
|
||||
|
||||
@@ -44,8 +44,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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@@ -40,11 +39,6 @@ func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigSe
|
||||
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
|
||||
}
|
||||
|
||||
@@ -74,8 +68,6 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
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{},
|
||||
@@ -422,23 +414,3 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -630,12 +630,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 {
|
||||
|
||||
@@ -48,13 +48,14 @@ type JwtService struct {
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
appConfigService *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 *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 +63,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 *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 +75,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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"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/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 +35,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 *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 *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 +70,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 *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)
|
||||
@@ -84,15 +94,16 @@ func TestJwtService_Init(t *testing.T) {
|
||||
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 +122,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 +143,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 +165,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()
|
||||
@@ -181,9 +195,12 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
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 +225,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 +262,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()
|
||||
@@ -299,9 +313,11 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
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"},
|
||||
@@ -342,7 +358,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"},
|
||||
@@ -367,7 +383,7 @@ 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"},
|
||||
@@ -388,7 +404,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
customMockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
|
||||
})
|
||||
service, _, _ := setupJwtService(t, customMockConfig)
|
||||
service, _, _ := setupJwtService(t, instanceID, customMockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user456"},
|
||||
@@ -408,9 +424,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -447,9 +462,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)
|
||||
@@ -486,9 +500,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)
|
||||
@@ -526,9 +539,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 +549,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 +563,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 +576,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 *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 +597,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 *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
rsaKeyPrecomputeOnce.Do(func() {
|
||||
@@ -599,10 +609,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 *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new P-256 ECDSA key
|
||||
@@ -610,11 +620,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 *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new Ed25519 key pair
|
||||
@@ -622,5 +632,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)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig())
|
||||
jwtService := initJwtService(t, db, appConfig, newTestEnvConfig())
|
||||
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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build unit
|
||||
|
||||
// This file is only imported by unit tests
|
||||
|
||||
package testing
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "로고 업로드",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
id="signup-token-list"
|
||||
withoutSearch={true}
|
||||
fetchCallback={userService.listSignupTokens}
|
||||
defaultSort={{ column: 'createdAt', direction: 'asc' }}
|
||||
bind:this={tableRef}
|
||||
{columns}
|
||||
{actions}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
140
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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=="
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user