mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-17 05:44:00 +03:00
Compare commits
21 Commits
v2.10.0
...
feat/huma-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8beb4921c1 | ||
|
|
c87b9a97aa | ||
|
|
00d2b0fea9 | ||
|
|
525946e94e | ||
|
|
bb03660bd7 | ||
|
|
9714296efb | ||
|
|
a3f27ec2ec | ||
|
|
316cf47ceb | ||
|
|
ef9ed8de32 | ||
|
|
217bf75b7c | ||
|
|
77398a558d | ||
|
|
187cd8ddcd | ||
|
|
d9ead47d19 | ||
|
|
7b5d6f5a9c | ||
|
|
3893376cba | ||
|
|
7348bdac50 | ||
|
|
69a6e22c7c | ||
|
|
cfda5f693b | ||
|
|
ff8e34cccf | ||
|
|
ff5e565eeb | ||
|
|
f8b441fb06 |
@@ -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,13 +4,14 @@ 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
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
|
||||
@@ -20,13 +21,11 @@ require (
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/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
|
||||
)
|
||||
@@ -69,17 +68,17 @@ require (
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/alphadose/haxmap v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.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
|
||||
@@ -118,6 +117,7 @@ require (
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
@@ -182,7 +182,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 +233,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
|
||||
|
||||
@@ -14,18 +14,18 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
|
||||
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||
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=
|
||||
@@ -82,6 +82,8 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
|
||||
github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -263,10 +265,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 +412,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 +446,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=
|
||||
@@ -519,8 +521,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -668,8 +670,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 +711,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 +724,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 +763,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=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -23,25 +25,25 @@ type apiPermissionResponseDto struct {
|
||||
// apiCreateDto is the payload for creating an API
|
||||
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
|
||||
type apiCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
Resource string `json:"resource" required:"true" maxLength:"350" unorm:"nfc"`
|
||||
}
|
||||
|
||||
// apiUpdateDto is the payload for updating an API
|
||||
// The resource identifier is intentionally not updatable
|
||||
type apiUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type apiPermissionInputDto struct {
|
||||
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" binding:"omitempty,max=200"`
|
||||
Key string `json:"key" required:"true" minLength:"1" maxLength:"128" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
Description *string `json:"description" required:"false" maxLength:"200"`
|
||||
}
|
||||
|
||||
// apiPermissionsUpdateDto replaces the full permission set of an API
|
||||
type apiPermissionsUpdateDto struct {
|
||||
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
|
||||
Permissions []apiPermissionInputDto `json:"permissions" required:"false"`
|
||||
}
|
||||
|
||||
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
|
||||
@@ -52,6 +54,28 @@ type clientApiAccessDto struct {
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" required:"false"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" required:"false"`
|
||||
}
|
||||
|
||||
func (d *apiCreateDto) Resolve(huma.Context) []error {
|
||||
if dto.ValidateResourceURI(d.Resource) {
|
||||
return nil
|
||||
}
|
||||
return []error{&huma.ErrorDetail{Location: "body.resource", Message: "Resource must be an absolute URI without whitespace or a fragment"}}
|
||||
}
|
||||
|
||||
func (d *clientApiAccessUpdateDto) Resolve(huma.Context) []error {
|
||||
var errs []error
|
||||
for _, id := range d.UserDelegatedPermissionIDs {
|
||||
if id == "" {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.userDelegatedPermissionIds", Message: "Permission IDs cannot be empty"})
|
||||
}
|
||||
}
|
||||
for _, id := range d.ClientPermissionIDs {
|
||||
if id == "" {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.clientPermissionIds", Message: "Permission IDs cannot be empty"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"context"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type listInput struct {
|
||||
httpapi.ListInput
|
||||
Search string `query:"search" required:"false"`
|
||||
}
|
||||
|
||||
type idInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type createInput struct {
|
||||
Body apiCreateDto
|
||||
}
|
||||
|
||||
type updateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body apiUpdateDto
|
||||
}
|
||||
|
||||
type permissionsInput struct {
|
||||
ID string `path:"id"`
|
||||
Body apiPermissionsUpdateDto
|
||||
}
|
||||
|
||||
type clientInput struct {
|
||||
ClientID string `path:"clientId"`
|
||||
}
|
||||
|
||||
type clientUpdateInput struct {
|
||||
ClientID string `path:"clientId"`
|
||||
Body clientApiAccessUpdateDto
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
@@ -17,219 +47,93 @@ func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List APIs
|
||||
// @Description Get a paginated list of APIs with optional search and sorting
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term to filter APIs by name or resource"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiResponseDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
func (h *handler) list(ctx context.Context, input *listInput) (*httpapi.BodyOutput[dto.Paginated[apiResponseDto]], error) {
|
||||
apis, pagination, err := h.service.List(ctx, input.Search, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]apiResponseDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiResponseDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
for i := range apis {
|
||||
if err := dto.MapStruct(apis[i], &items[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
items[i] = item
|
||||
items[i].Resource = apis[i].Audience
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[apiResponseDto]]{Body: dto.Paginated[apiResponseDto]{Data: items, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get API by ID
|
||||
// @Description Retrieve a single API including its permissions
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
func (h *handler) get(ctx context.Context, input *idInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Get(ctx, nil, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API
|
||||
// @Description Create a new API resource server
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Create(ctx, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update API
|
||||
// @Description Update an existing API by ID
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
func (h *handler) update(ctx context.Context, input *updateInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Update(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete API
|
||||
// @Description Delete an API by ID
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (h *handler) delete(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := h.service.Delete(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
// @Summary Update API permissions
|
||||
// @Description Replace the full set of permissions for an API
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
func (h *handler) updatePermissions(ctx context.Context, input *permissionsInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.UpdatePermissions(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
// @Summary Get client API access
|
||||
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
func (h *handler) getClientAccess(ctx context.Context, input *clientInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
|
||||
access, err := h.service.GetClientAPIAccess(ctx, input.ClientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(access)}, nil
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
// @Summary Update client API access
|
||||
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
func (h *handler) updateClientAccess(ctx context.Context, input *clientUpdateInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
|
||||
applied, err := h.service.SetClientAPIAccess(ctx, input.ClientID, ClientAPIAccess(input.Body))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(applied)}, nil
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
dto := clientApiAccessDto(access)
|
||||
if dto.UserDelegatedPermissionIDs == nil {
|
||||
dto.UserDelegatedPermissionIDs = []string{}
|
||||
func newClientAPIAccessDTO(access ClientAPIAccess) clientApiAccessDto {
|
||||
output := clientApiAccessDto(access)
|
||||
if output.UserDelegatedPermissionIDs == nil {
|
||||
output.UserDelegatedPermissionIDs = []string{}
|
||||
}
|
||||
if dto.ClientPermissionIDs == nil {
|
||||
dto.ClientPermissionIDs = []string{}
|
||||
if output.ClientPermissionIDs == nil {
|
||||
output.ClientPermissionIDs = []string{}
|
||||
}
|
||||
return dto
|
||||
return output
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func mapAPI(item API) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
var output apiResponseDto
|
||||
if err := dto.MapStruct(item, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
output.Resource = item.Audience
|
||||
return &httpapi.BodyOutput[apiResponseDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
@@ -59,20 +61,70 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the admin CRUD endpoints
|
||||
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation)) {
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-apis",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/apis",
|
||||
Summary: "List APIs",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.list, adminAuth)
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-api",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/apis",
|
||||
Summary: "Create API",
|
||||
Tags: []string{"APIs"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.create, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-api",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/apis/{id}",
|
||||
Summary: "Get API by ID",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.get, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-api",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/apis/{id}",
|
||||
Summary: "Update API",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.update, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-api",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/apis/{id}",
|
||||
Summary: "Delete API",
|
||||
Tags: []string{"APIs"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.delete, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-api-permissions",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/apis/{id}/permissions",
|
||||
Summary: "Update API permissions",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.updatePermissions, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-client-api-access",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/api-access/{clientId}",
|
||||
Summary: "Get client API access",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.getClientAccess, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-client-api-access",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/api-access/{clientId}",
|
||||
Summary: "Update client API access",
|
||||
Tags: []string{"APIs"},
|
||||
}, m.handler.updateClientAccess, adminAuth)
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ func (s *Service) List(ctx context.Context, search string, listRequestOptions ut
|
||||
Preload("Permissions").
|
||||
Model(&API{})
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
if listRequestOptions.SortColumn == "resource" {
|
||||
listRequestOptions.SortColumn = "audience"
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
)
|
||||
|
||||
type apiKeyCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" unorm:"nfc"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
Name string `json:"name" required:"true" minLength:"3" maxLength:"50" unorm:"nfc"`
|
||||
Description *string `json:"description" required:"false" unorm:"nfc"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"`
|
||||
}
|
||||
|
||||
type apiKeyRenewDto struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" required:"true"`
|
||||
}
|
||||
|
||||
type apiKeyDto struct {
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"context"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type createInput struct {
|
||||
Body apiKeyCreateDto
|
||||
}
|
||||
|
||||
type renewInput struct {
|
||||
ID string `path:"id"`
|
||||
Body apiKeyRenewDto
|
||||
}
|
||||
|
||||
type idInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
@@ -17,123 +28,46 @@ func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List API keys
|
||||
// @Description Get a paginated list of API keys belonging to the current user
|
||||
// @Tags API Keys
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiKeyDto]
|
||||
// @Router /api/api-keys [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
|
||||
func (h *handler) list(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[apiKeyDto]], error) {
|
||||
apiKeys, pagination, err := h.service.ListApiKeys(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiKeysDto []apiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []apiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
|
||||
Data: apiKeysDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[apiKeyDto]]{Body: dto.Paginated[apiKeyDto]{Data: output, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API key
|
||||
// @Description Create a new API key for the current user
|
||||
// @Tags API Keys
|
||||
// @Param api_key body apiKeyCreateDto true "API key information"
|
||||
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
|
||||
// @Router /api/api-keys [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var input apiKeyCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
|
||||
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
|
||||
apiKey, token, err := h.service.CreateApiKey(ctx, httpapi.UserID(ctx), input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
return mapAPIKeyResponse(apiKey, token)
|
||||
}
|
||||
|
||||
// renew godoc
|
||||
// @Summary Renew API key
|
||||
// @Description Renew an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
|
||||
// @Router /api/api-keys/{id}/renew [post]
|
||||
func (h *handler) renew(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
var input apiKeyRenewDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
|
||||
func (h *handler) renew(ctx context.Context, input *renewInput) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
|
||||
apiKey, token, err := h.service.RenewApiKey(ctx, httpapi.UserID(ctx), input.ID, input.Body.ExpiresAt.ToTime())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
return mapAPIKeyResponse(apiKey, token)
|
||||
}
|
||||
|
||||
// revoke godoc
|
||||
// @Summary Revoke API key
|
||||
// @Description Revoke (delete) an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/api-keys/{id} [delete]
|
||||
func (h *handler) revoke(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (h *handler) revoke(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := h.service.RevokeApiKey(ctx, httpapi.UserID(ctx), input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func mapAPIKeyResponse(apiKey ApiKey, token string) (*httpapi.BodyOutput[apiKeyResponseDto], error) {
|
||||
var output apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[apiKeyResponseDto]{Body: apiKeyResponseDto{ApiKey: output, Token: token}}, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package apikey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
@@ -33,12 +35,40 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
|
||||
// RegisterRoutes mounts the API key management endpoints
|
||||
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
|
||||
group := apiGroup.Group("/api-keys")
|
||||
group.GET("", auth, m.handler.list)
|
||||
group.POST("", authWithoutApiKey, m.handler.create)
|
||||
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
|
||||
group.DELETE("/:id", auth, m.handler.revoke)
|
||||
func (m *Module) RegisterRoutes(api huma.API, auth, authWithoutAPIKey func(*huma.Operation)) {
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-api-keys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/api-keys",
|
||||
Summary: "List API keys",
|
||||
Tags: []string{"API Keys"},
|
||||
}, m.handler.list, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-api-key",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/api-keys",
|
||||
Summary: "Create API key",
|
||||
Tags: []string{"API Keys"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.create, authWithoutAPIKey)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "renew-api-key",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/api-keys/{id}/renew",
|
||||
Summary: "Renew API key",
|
||||
Tags: []string{"API Keys"},
|
||||
}, m.handler.renew, authWithoutAPIKey)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "revoke-api-key",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/api-keys/{id}",
|
||||
Summary: "Revoke API key",
|
||||
Tags: []string{"API Keys"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.revoke, auth)
|
||||
}
|
||||
|
||||
// ValidateApiKey resolves the user that owns the given raw API key
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// When building for E2E tests, add the e2etest controller
|
||||
func init() {
|
||||
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
|
||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
registerTestControllers = []func(api huma.API, db *gorm.DB, svc *services){
|
||||
func(api huma.API, db *gorm.DB, svc *services) {
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||
@@ -24,7 +24,7 @@ func init() {
|
||||
return
|
||||
}
|
||||
|
||||
controller.NewTestController(apiGroup, testService)
|
||||
controller.NewTestController(api, testService)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,11 +28,12 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||
)
|
||||
|
||||
// This is used to register additional controllers for tests
|
||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
|
||||
var registerTestControllers []func(api huma.API, db *gorm.DB, svc *services)
|
||||
|
||||
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
|
||||
r, err := initEngine()
|
||||
@@ -68,7 +70,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 +91,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 +105,8 @@ func configureEngine(r *gin.Engine) {
|
||||
common.Name,
|
||||
otelgin.WithFilter(shouldTraceRequest)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldTraceRequest reports whether an incoming request should be traced.
|
||||
@@ -137,45 +145,51 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
|
||||
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
|
||||
baseGroup := r.Group("/", rateLimitMiddleware.Add(middleware.RateLimitAPI))
|
||||
apiGroup := baseGroup.Group("/api")
|
||||
api := httpapi.New(r, baseGroup)
|
||||
|
||||
apiGroup := r.Group("/api", apiRateLimitMiddleware)
|
||||
baseGroup := r.Group("/", apiRateLimitMiddleware)
|
||||
|
||||
svc.apiKeyModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().Add(),
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
|
||||
svc.apiKeyModule.RegisterRoutes(api,
|
||||
authMiddleware.WithAdminNotRequired().Huma(api),
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api),
|
||||
)
|
||||
svc.webauthnModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
|
||||
svc.webauthnModule.RegisterRoutes(api,
|
||||
authMiddleware.WithAdminNotRequired().Huma(api),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnLogin),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
svc.userSignUpModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitSignup),
|
||||
controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(api, authMiddleware, fileSizeLimitMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(api, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(api, authMiddleware, svc.userGroupService)
|
||||
svc.apiModule.RegisterRoutes(api, authMiddleware.Huma(api))
|
||||
controller.NewCustomClaimController(api, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(api, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(api, authMiddleware, svc.scimService)
|
||||
svc.userSignUpModule.RegisterRoutes(api,
|
||||
authMiddleware.Huma(api),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitSignup),
|
||||
)
|
||||
|
||||
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
|
||||
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
|
||||
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
|
||||
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)
|
||||
svc.oidcModule.RegisterTypedRoutes(api, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api))
|
||||
|
||||
registerTestRoutes(apiGroup, db, svc)
|
||||
registerTestRoutes(api, db, svc)
|
||||
|
||||
controller.NewWellKnownController(baseGroup, svc.jwtService)
|
||||
controller.NewWellKnownController(api, svc.jwtService)
|
||||
|
||||
// These are not rate-limited.
|
||||
controller.NewHealthzController(r)
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "healthz",
|
||||
Method: http.MethodGet,
|
||||
Path: "/healthz",
|
||||
Summary: "Health check",
|
||||
Tags: []string{"Health"},
|
||||
}, http.StatusNoContent)
|
||||
|
||||
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
|
||||
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
|
||||
@@ -184,13 +198,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
func registerTestRoutes(api huma.API, db *gorm.DB, svc *services) {
|
||||
if common.EnvConfig.AppEnv.IsProduction() {
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range registerTestControllers {
|
||||
f(apiGroup, db, svc)
|
||||
f(api, db, svc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
109
backend/internal/bootstrap/router_huma_test.go
Normal file
109
backend/internal/bootstrap/router_huma_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestHumaRouterOpenAPI(t *testing.T) {
|
||||
originalConfig := common.EnvConfig
|
||||
t.Cleanup(func() { common.EnvConfig = originalConfig })
|
||||
common.EnvConfig.AppEnv = common.AppEnvTest
|
||||
common.EnvConfig.AppURL = "https://test.example.com"
|
||||
common.EnvConfig.InternalAppURL = "https://test.example.com"
|
||||
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
common.EnvConfig.DisableRateLimiting = true
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
scheduler, err := job.NewScheduler()
|
||||
require.NoError(t, err)
|
||||
services, err := initServices(t.Context(), db, "test-instance", http.DefaultClient, map[string]string{}, fileStorage, scheduler)
|
||||
require.NoError(t, err)
|
||||
router, err := initEngine()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, registerRoutes(router, db, services, nil))
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
|
||||
var document struct {
|
||||
Paths map[string]map[string]struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Responses map[string]any `json:"responses"`
|
||||
} `json:"paths"`
|
||||
Components struct {
|
||||
SecuritySchemes map[string]any `json:"securitySchemes"`
|
||||
} `json:"components"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &document))
|
||||
|
||||
for _, path := range []string{
|
||||
"/authorize",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/jwks.json",
|
||||
"/api/users",
|
||||
"/api/user-groups",
|
||||
"/api/oidc/token",
|
||||
"/api/oidc/interactions/{id}",
|
||||
"/api/webauthn/reauthenticate",
|
||||
"/api/signup",
|
||||
"/api/api-keys",
|
||||
"/api/apis",
|
||||
"/healthz",
|
||||
} {
|
||||
require.Contains(t, document.Paths, path)
|
||||
}
|
||||
|
||||
operationIDs := map[string]struct{}{}
|
||||
for _, methods := range document.Paths {
|
||||
for _, operation := range methods {
|
||||
require.NotEmpty(t, operation.OperationID)
|
||||
require.NotContains(t, operation.Responses, "422")
|
||||
_, duplicate := operationIDs[operation.OperationID]
|
||||
require.False(t, duplicate, "duplicate operation ID %q", operation.OperationID)
|
||||
operationIDs[operation.OperationID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, scheme := range []string{"BearerAuth", "SessionCookie", "ApiKeyAuth", "OIDCAccessToken", "OIDCClientBasic"} {
|
||||
require.Contains(t, document.Components.SecuritySchemes, scheme)
|
||||
}
|
||||
require.NotContains(t, response.Body.String(), `"$schema"`)
|
||||
require.NotContains(t, response.Header().Get("Link"), "schema")
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users", nil))
|
||||
require.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
|
||||
require.JSONEq(t, `{"error":"You are not signed in"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/signup/setup", nil)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Header().Get("Content-Security-Policy"), "https://cdn.jsdelivr.net")
|
||||
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
|
||||
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
newHTTPServer(router, nil).Handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodHead, "/healthz", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
require.Empty(t, response.Body.String())
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -87,6 +87,14 @@ type NotSignedInError struct{}
|
||||
|
||||
func (e NotSignedInError) Error() string { return "You are not signed in" }
|
||||
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
func (e NotSignedInError) Is(target error) bool {
|
||||
switch target.(type) {
|
||||
case NotSignedInError, *NotSignedInError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type MissingPermissionError struct{}
|
||||
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||
// @Summary Create a new application configuration controller
|
||||
// @Description Initialize routes for application configuration
|
||||
// @Tags Application Configuration
|
||||
type appConfigUpdateInput struct {
|
||||
Body dto.AppConfigUpdateDto
|
||||
}
|
||||
|
||||
// NewAppConfigController registers application configuration endpoints
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
api huma.API,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
appConfigService *service.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
ldapService *service.LdapService,
|
||||
) {
|
||||
controller := &AppConfigController{appConfigService: appConfigService, emailService: emailService, ldapService: ldapService}
|
||||
auth := authMiddleware.Huma(api)
|
||||
|
||||
acc := &AppConfigController{
|
||||
appConfigService: appConfigService,
|
||||
emailService: emailService,
|
||||
ldapService: ldapService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-public-application-configuration",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-configuration",
|
||||
Summary: "List public application configurations",
|
||||
Tags: []string{"Application Configuration"},
|
||||
}, controller.listAppConfigHandler)
|
||||
|
||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-all-application-configuration",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-configuration/all",
|
||||
Summary: "List all application configurations",
|
||||
Tags: []string{"Application Configuration"},
|
||||
}, controller.listAllAppConfigHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-application-configuration",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-configuration",
|
||||
Summary: "Update application configurations",
|
||||
Tags: []string{"Application Configuration"},
|
||||
}, controller.updateAppConfigHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-email-configuration",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/application-configuration/test-email",
|
||||
Summary: "Send test email",
|
||||
Tags: []string{"Application Configuration"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.testEmailHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "sync-ldap",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/application-configuration/sync-ldap",
|
||||
Summary: "Synchronize LDAP",
|
||||
Tags: []string{"Application Configuration"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.syncLDAPHandler, auth)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
@@ -43,121 +79,51 @@ type AppConfigController struct {
|
||||
ldapService *service.LdapService
|
||||
}
|
||||
|
||||
// listAppConfigHandler godoc
|
||||
// @Summary List public application configurations
|
||||
// @Description Get all public application configurations
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Router /api/application-configuration [get]
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto], error) {
|
||||
configuration := acc.appConfigService.ListAppConfig(false)
|
||||
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
|
||||
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
|
||||
Key: "uiConfigDisabled",
|
||||
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
|
||||
Type: "boolean",
|
||||
})
|
||||
|
||||
// Manually add tracingEnabled, derived from the OTel environment, so the frontend only exports traces when the backend can forward them to a collector
|
||||
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
|
||||
Key: "tracingEnabled",
|
||||
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
|
||||
Type: "boolean",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
output = append(output,
|
||||
dto.PublicAppConfigVariableDto{Key: "uiConfigDisabled", Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled), Type: "boolean"},
|
||||
dto.PublicAppConfigVariableDto{Key: "tracingEnabled", Value: strconv.FormatBool(tracing.FrontendTracingEnabled()), Type: "boolean"},
|
||||
)
|
||||
return &httpapi.BodyOutput[[]dto.PublicAppConfigVariableDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// listAllAppConfigHandler godoc
|
||||
// @Summary List all application configurations
|
||||
// @Description Get all application configurations including private ones
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Router /api/application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
|
||||
configuration := acc.appConfigService.ListAppConfig(true)
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// updateAppConfigHandler godoc
|
||||
// @Summary Update application configurations
|
||||
// @Description Update application configuration settings
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Router /api/application-configuration [put]
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
|
||||
func (acc *AppConfigController) updateAppConfigHandler(ctx context.Context, input *appConfigUpdateInput) (*httpapi.BodyOutput[[]dto.AppConfigVariableDto], error) {
|
||||
saved, err := acc.appConfigService.UpdateAppConfig(ctx, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(saved, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return &httpapi.BodyOutput[[]dto.AppConfigVariableDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// syncLdapHandler godoc
|
||||
// @Summary Synchronize LDAP
|
||||
// @Description Manually trigger LDAP synchronization
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/sync-ldap [post]
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
err := acc.ldapService.SyncAll(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (acc *AppConfigController) syncLDAPHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := acc.ldapService.SyncAll(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// testEmailHandler godoc
|
||||
// @Summary Send test email
|
||||
// @Description Send a test email to verify email configuration
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/test-email [post]
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (acc *AppConfigController) testEmailHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := acc.emailService.SendTestEmail(ctx, httpapi.UserID(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
@@ -1,292 +1,287 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
func NewAppImagesController(
|
||||
group *gin.RouterGroup,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
appImagesService *service.AppImagesService,
|
||||
) {
|
||||
controller := &AppImagesController{
|
||||
appImagesService: appImagesService,
|
||||
}
|
||||
type imageGetInput struct {
|
||||
Light string `query:"light" default:"true" required:"false"`
|
||||
}
|
||||
|
||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||
group.GET("/application-images/email", controller.getEmailLogoHandler)
|
||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||
type imageUploadForm struct {
|
||||
File huma.FormFile `form:"file" required:"true"`
|
||||
}
|
||||
|
||||
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
|
||||
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
type imageUploadInput struct {
|
||||
RawBody huma.MultipartFormFiles[imageUploadForm]
|
||||
}
|
||||
|
||||
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||
type logoUploadInput struct {
|
||||
Light string `query:"light" default:"true" required:"false"`
|
||||
RawBody huma.MultipartFormFiles[imageUploadForm]
|
||||
}
|
||||
|
||||
type imageOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
ContentLength int64 `header:"Content-Length"`
|
||||
CacheControl string `header:"Cache-Control"`
|
||||
Body func(huma.Context)
|
||||
}
|
||||
|
||||
func NewAppImagesController(api huma.API, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, appImagesService *service.AppImagesService) {
|
||||
controller := &AppImagesController{appImagesService: appImagesService}
|
||||
auth := authMiddleware.Huma(api)
|
||||
uploadLimit := httpapi.WithMiddleware(fileSizeLimitMiddleware.Huma(api, 2<<20))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-application-logo",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-images/logo",
|
||||
Summary: "Get logo image",
|
||||
Tags: []string{"Application Images"},
|
||||
}, controller.getLogoHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-email-logo",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-images/email",
|
||||
Summary: "Get email logo image",
|
||||
Tags: []string{"Application Images"},
|
||||
}, controller.getEmailLogoHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-background-image",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-images/background",
|
||||
Summary: "Get background image",
|
||||
Tags: []string{"Application Images"},
|
||||
}, controller.getBackgroundImageHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-favicon",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-images/favicon",
|
||||
Summary: "Get favicon",
|
||||
Tags: []string{"Application Images"},
|
||||
}, controller.getFaviconHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-default-profile-picture",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/application-images/default-profile-picture",
|
||||
Summary: "Get default profile picture",
|
||||
Tags: []string{"Application Images"},
|
||||
}, controller.getDefaultProfilePicture, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-application-logo",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-images/logo",
|
||||
Summary: "Update logo",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateLogoHandler, auth, uploadLimit)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-email-logo",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-images/email",
|
||||
Summary: "Update email logo",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateEmailLogoHandler, auth, uploadLimit)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-background-image",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-images/background",
|
||||
Summary: "Update background image",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateBackgroundImageHandler, auth, uploadLimit)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-favicon",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-images/favicon",
|
||||
Summary: "Update favicon",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateFaviconHandler, auth, uploadLimit)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-default-profile-picture",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/application-images/default-profile-picture",
|
||||
Summary: "Update default profile picture",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateDefaultProfilePicture, auth, uploadLimit)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-background-image",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/application-images/background",
|
||||
Summary: "Delete background image",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.deleteBackgroundImageHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-default-profile-picture",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/application-images/default-profile-picture",
|
||||
Summary: "Delete default profile picture",
|
||||
Tags: []string{"Application Images"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.deleteDefaultProfilePicture, auth)
|
||||
}
|
||||
|
||||
type AppImagesController struct {
|
||||
appImagesService *service.AppImagesService
|
||||
}
|
||||
|
||||
// getLogoHandler godoc
|
||||
// @Summary Get logo image
|
||||
// @Description Get the logo image for the application
|
||||
// @Tags Application Images
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Produce image/svg+xml
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /api/application-images/logo [get]
|
||||
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||
func (c *AppImagesController) getLogoHandler(ctx context.Context, input *imageGetInput) (*imageOutput, error) {
|
||||
lightLogo, _ := strconv.ParseBool(input.Light)
|
||||
imageName := "logoLight"
|
||||
if !lightLogo {
|
||||
imageName = "logoDark"
|
||||
}
|
||||
|
||||
c.getImage(ctx, imageName)
|
||||
return c.getImage(ctx, imageName)
|
||||
}
|
||||
|
||||
// getEmailLogoHandler godoc
|
||||
// @Summary Get email logo image
|
||||
// @Description Get the email logo image for use in emails
|
||||
// @Tags Application Images
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Email logo image"
|
||||
// @Router /api/application-images/email [get]
|
||||
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "logoEmail")
|
||||
func (c *AppImagesController) getEmailLogoHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
|
||||
return c.getImage(ctx, "logoEmail")
|
||||
}
|
||||
|
||||
// getBackgroundImageHandler godoc
|
||||
// @Summary Get background image
|
||||
// @Description Get the background image for the application
|
||||
// @Tags Application Images
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Background image"
|
||||
// @Router /api/application-images/background [get]
|
||||
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "background")
|
||||
func (c *AppImagesController) getBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
|
||||
return c.getImage(ctx, "background")
|
||||
}
|
||||
|
||||
// getFaviconHandler godoc
|
||||
// @Summary Get favicon
|
||||
// @Description Get the favicon for the application
|
||||
// @Tags Application Images
|
||||
// @Produce image/x-icon
|
||||
// @Success 200 {file} binary "Favicon image"
|
||||
// @Router /api/application-images/favicon [get]
|
||||
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "favicon")
|
||||
func (c *AppImagesController) getFaviconHandler(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
|
||||
return c.getImage(ctx, "favicon")
|
||||
}
|
||||
|
||||
// getDefaultProfilePicture godoc
|
||||
// @Summary Get default profile picture image
|
||||
// @Description Get the default profile picture image for the application
|
||||
// @Tags Application Images
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Default profile picture image"
|
||||
// @Router /api/application-images/default-profile-picture [get]
|
||||
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
|
||||
c.getImage(ctx, "default-profile-picture")
|
||||
func (c *AppImagesController) getDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*imageOutput, error) {
|
||||
return c.getImage(ctx, "default-profile-picture")
|
||||
}
|
||||
|
||||
// updateLogoHandler godoc
|
||||
// @Summary Update logo
|
||||
// @Description Update the application logo
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Param file formData file true "Logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/logo [put]
|
||||
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateLogoHandler(ctx context.Context, input *logoUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||
lightLogo, _ := strconv.ParseBool(input.Light)
|
||||
imageName := "logoLight"
|
||||
if !lightLogo {
|
||||
imageName = "logoDark"
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
if err := c.appImagesService.UpdateImage(ctx, file, imageName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updateEmailLogoHandler godoc
|
||||
// @Summary Update email logo
|
||||
// @Description Update the email logo for use in emails
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Email logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/email [put]
|
||||
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateEmailLogoHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
mimeType := utils.GetImageMimeType(fileType)
|
||||
|
||||
mimeType := utils.GetImageMimeType(utils.GetFileExtension(file.Filename))
|
||||
if mimeType != "image/png" && mimeType != "image/jpeg" {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
|
||||
return
|
||||
return nil, &common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return c.updateImage(ctx, file, "logoEmail")
|
||||
}
|
||||
|
||||
// updateBackgroundImageHandler godoc
|
||||
// @Summary Update background image
|
||||
// @Description Update the application background image
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Background image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [put]
|
||||
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateBackgroundImageHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return c.updateImage(ctx, file, "background")
|
||||
}
|
||||
|
||||
// deleteBackgroundImageHandler godoc
|
||||
// @Summary Delete background image
|
||||
// @Description Delete the application background image
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [delete]
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateFaviconHandler godoc
|
||||
// @Summary Update favicon
|
||||
// @Description Update the application favicon
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/favicon [put]
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
|
||||
mimeType := utils.GetImageMimeType(strings.ToLower(utils.GetFileExtension(file.Filename)))
|
||||
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
|
||||
return
|
||||
return nil, &common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"}
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return c.updateImage(ctx, file, "favicon")
|
||||
}
|
||||
|
||||
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
|
||||
func (c *AppImagesController) updateDefaultProfilePicture(ctx context.Context, input *imageUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
ctx.Header("Content-Type", mimeType)
|
||||
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
|
||||
return c.updateImage(ctx, file, "default-profile-picture")
|
||||
}
|
||||
|
||||
// updateDefaultProfilePicture godoc
|
||||
// @Summary Update default profile picture image
|
||||
// @Description Update the default profile picture image
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Profile picture image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [put]
|
||||
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateImage(ctx context.Context, file *multipart.FileHeader, name string) (*httpapi.EmptyOutput, error) {
|
||||
if err := c.appImagesService.UpdateImage(ctx, file, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func uploadFile(form *multipart.Form) (*multipart.FileHeader, error) {
|
||||
files := form.File["file"]
|
||||
if len(files) == 0 {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return files[0], nil
|
||||
}
|
||||
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := c.appImagesService.DeleteImage(ctx, "background"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *AppImagesController) deleteDefaultProfilePicture(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := c.appImagesService.DeleteImage(ctx, "default-profile-picture"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *AppImagesController) getImage(ctx context.Context, name string) (*imageOutput, error) {
|
||||
reader, size, mimeType, err := c.appImagesService.GetImage(ctx, name)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
cacheControl := ""
|
||||
if !httpapi.QueryPresent(ctx, "skipCache") {
|
||||
cacheControl = utils.CacheControlValue(15*time.Minute, 24*time.Hour)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteDefaultProfilePicture godoc
|
||||
// @Summary Delete default profile picture image
|
||||
// @Description Delete the default profile picture image
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [delete]
|
||||
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return &imageOutput{
|
||||
ContentType: mimeType,
|
||||
ContentLength: size,
|
||||
CacheControl: cacheControl,
|
||||
Body: func(streamCtx huma.Context) {
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(streamCtx.BodyWriter(), reader)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,145 +1,114 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewAuditLogController creates a new controller for audit log management
|
||||
// @Summary Audit log controller
|
||||
// @Description Initializes API endpoints for accessing audit logs
|
||||
// @Tags Audit Logs
|
||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
|
||||
alc := AuditLogController{
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
// NewAuditLogController registers audit log routes
|
||||
func NewAuditLogController(api huma.API, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
|
||||
controller := &AuditLogController{auditLogService: auditLogService}
|
||||
adminAuth := authMiddleware.Huma(api)
|
||||
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
|
||||
|
||||
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
|
||||
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-all-audit-logs",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/audit-logs/all",
|
||||
Summary: "List all audit logs",
|
||||
Tags: []string{"Audit Logs"},
|
||||
}, controller.listAllAuditLogsHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-current-user-audit-logs",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/audit-logs",
|
||||
Summary: "List audit logs for the current user",
|
||||
Tags: []string{"Audit Logs"},
|
||||
}, controller.listAuditLogsForUserHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-audit-log-client-names",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/audit-logs/filters/client-names",
|
||||
Summary: "List client names",
|
||||
Tags: []string{"Audit Logs"},
|
||||
}, controller.listClientNamesHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-audit-log-users",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/audit-logs/filters/users",
|
||||
Summary: "List users with IDs",
|
||||
Tags: []string{"Audit Logs"},
|
||||
}, controller.listUserNamesWithIDsHandler, adminAuth)
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
auditLogService *service.AuditLogService
|
||||
}
|
||||
|
||||
// listAuditLogsForUserHandler godoc
|
||||
// @Summary List audit logs
|
||||
// @Description Get a paginated list of audit logs for the current user
|
||||
// @Tags Audit Logs
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs [get]
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
// Fetch audit logs for the user
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) {
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map the audit logs to DTOs
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
logsDTOs, err := alc.mapAuditLogs(logs, false)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add device information to the logs
|
||||
for i, logsDto := range logsDtos {
|
||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// listAllAuditLogsHandler godoc
|
||||
// @Summary List all audit logs
|
||||
// @Description Get a paginated list of all audit logs (admin only)
|
||||
// @Tags Audit Logs
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs/all [get]
|
||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
|
||||
func (alc *AuditLogController) listAllAuditLogsHandler(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]], error) {
|
||||
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(ctx, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
logsDTOs, err := alc.mapAuditLogs(logs, true)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, logsDto := range logsDtos {
|
||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDto.Username = logs[i].User.Username
|
||||
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.AuditLogDto]]{Body: dto.Paginated[dto.AuditLogDto]{Data: logsDTOs, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// listClientNamesHandler godoc
|
||||
// @Summary List client names
|
||||
// @Description Get a list of all client names for audit log filtering
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {array} string "List of client names"
|
||||
// @Router /api/audit-logs/filters/client-names [get]
|
||||
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
|
||||
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (alc *AuditLogController) mapAuditLogs(logs []model.AuditLog, includeUsername bool) ([]dto.AuditLogDto, error) {
|
||||
var logsDTOs []dto.AuditLogDto
|
||||
if err := dto.MapStructList(logs, &logsDTOs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, names)
|
||||
for i := range logsDTOs {
|
||||
logsDTOs[i].Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDTOs[i].ActorUsername = logsDTOs[i].Data["actorUsername"]
|
||||
if includeUsername {
|
||||
logsDTOs[i].Username = logs[i].User.Username
|
||||
}
|
||||
}
|
||||
return logsDTOs, nil
|
||||
}
|
||||
|
||||
// listUserNamesWithIdsHandler godoc
|
||||
// @Summary List users with IDs
|
||||
// @Description Get a list of all usernames with their IDs for audit log filtering
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
|
||||
// @Router /api/audit-logs/filters/users [get]
|
||||
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
|
||||
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
|
||||
func (alc *AuditLogController) listClientNamesHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
|
||||
names, err := alc.auditLogService.ListClientNames(ctx)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
return &httpapi.BodyOutput[[]string]{Body: names}, nil
|
||||
}
|
||||
|
||||
func (alc *AuditLogController) listUserNamesWithIDsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
users, err := alc.auditLogService.ListUsernamesWithIds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[map[string]string]{Body: users}, nil
|
||||
}
|
||||
|
||||
@@ -1,115 +1,91 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewCustomClaimController creates a new controller for custom claim management
|
||||
// @Summary Custom claim management controller
|
||||
// @Description Initializes all custom claim-related API endpoints
|
||||
// @Tags Custom Claims
|
||||
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||
type customClaimUserInput struct {
|
||||
UserID string `path:"userId"`
|
||||
Body []dto.CustomClaimCreateDto `required:"true"`
|
||||
}
|
||||
|
||||
customClaimsGroup := group.Group("/custom-claims")
|
||||
customClaimsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
|
||||
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
|
||||
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
}
|
||||
type customClaimUserGroupInput struct {
|
||||
UserGroupID string `path:"userGroupId"`
|
||||
Body []dto.CustomClaimCreateDto `required:"true"`
|
||||
}
|
||||
|
||||
// NewCustomClaimController registers custom claim management routes
|
||||
func NewCustomClaimController(api huma.API, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
controller := &CustomClaimController{customClaimService: customClaimService}
|
||||
auth := authMiddleware.Huma(api)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-custom-claim-suggestions",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/custom-claims/suggestions",
|
||||
Summary: "Get custom claim suggestions",
|
||||
Tags: []string{"Custom Claims"},
|
||||
}, controller.getSuggestionsHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-user-custom-claims",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/custom-claims/user/{userId}",
|
||||
Summary: "Update custom claims for a user",
|
||||
Tags: []string{"Custom Claims"},
|
||||
}, controller.updateCustomClaimsForUserHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-user-group-custom-claims",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/custom-claims/user-group/{userGroupId}",
|
||||
Summary: "Update custom claims for a user group",
|
||||
Tags: []string{"Custom Claims"},
|
||||
}, controller.updateCustomClaimsForUserGroupHandler, auth)
|
||||
}
|
||||
|
||||
type CustomClaimController struct {
|
||||
customClaimService *service.CustomClaimService
|
||||
}
|
||||
|
||||
// getSuggestionsHandler godoc
|
||||
// @Summary Get custom claim suggestions
|
||||
// @Description Get a list of suggested custom claim names
|
||||
// @Tags Custom Claims
|
||||
// @Produce json
|
||||
// @Success 200 {array} string "List of suggested custom claim names"
|
||||
// @Router /api/custom-claims/suggestions [get]
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[[]string], error) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions(ctx)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
return &httpapi.BodyOutput[[]string]{Body: claims}, nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserHandler godoc
|
||||
// @Summary Update custom claims for a user
|
||||
// @Description Update or create custom claims for a specific user
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userId path string true "User ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user/{userId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
|
||||
func (ccc *CustomClaimController) updateCustomClaimsForUserHandler(ctx context.Context, input *customClaimUserInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) {
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(ctx, input.UserID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroupHandler godoc
|
||||
// @Summary Update custom claims for a user group
|
||||
// @Description Update or create custom claims for a specific user group
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userGroupId path string true "User Group ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userGroupId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
|
||||
func (ccc *CustomClaimController) updateCustomClaimsForUserGroupHandler(ctx context.Context, input *customClaimUserGroupInput) (*httpapi.BodyOutput[[]dto.CustomClaimDto], error) {
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(ctx, input.UserGroupID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
return &httpapi.BodyOutput[[]dto.CustomClaimDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -3,150 +3,167 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||
testController := &TestController{TestService: testService}
|
||||
type testResetInput struct {
|
||||
SkipLDAP string `query:"skip-ldap" required:"false"`
|
||||
SkipSeed string `query:"skip-seed" required:"false"`
|
||||
}
|
||||
|
||||
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||
group.POST("/test/accesstoken", testController.signAccessToken)
|
||||
group.POST("/test/refreshtoken", testController.signRefreshToken)
|
||||
type testExternalIDPInput struct {
|
||||
Body struct {
|
||||
Audience string `json:"aud" required:"true"`
|
||||
Issuer string `json:"iss" required:"true"`
|
||||
Subject string `json:"sub" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
|
||||
group.POST("/externalidp/sign", testController.externalIdPSignToken)
|
||||
type testAccessTokenInput struct {
|
||||
Body struct {
|
||||
UserID string `json:"user" required:"true"`
|
||||
ClientID string `json:"client" required:"true"`
|
||||
Expired bool `json:"expired" required:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
type testRefreshTokenInput struct {
|
||||
Body struct {
|
||||
UserID string `json:"user" required:"true"`
|
||||
ClientID string `json:"client" required:"true"`
|
||||
RefreshToken string `json:"rt" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
type testBytesOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func NewTestController(api huma.API, testService *service.TestService) {
|
||||
controller := &TestController{TestService: testService}
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-reset",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test/reset",
|
||||
Tags: []string{"E2E Test"},
|
||||
Hidden: true,
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.resetAndSeedHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-sign-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test/accesstoken",
|
||||
Tags: []string{"E2E Test"},
|
||||
Hidden: true,
|
||||
}, controller.signAccessToken)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-sign-refresh-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test/refreshtoken",
|
||||
Tags: []string{"E2E Test"},
|
||||
Hidden: true,
|
||||
}, controller.signRefreshToken)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-external-idp-jwks",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/externalidp/jwks.json",
|
||||
Tags: []string{"E2E Test"},
|
||||
Hidden: true,
|
||||
}, controller.externalIDPJWKS)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "test-external-idp-sign",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/externalidp/sign",
|
||||
Tags: []string{"E2E Test"},
|
||||
Hidden: true,
|
||||
}, controller.externalIDPSignToken)
|
||||
}
|
||||
|
||||
type TestController struct {
|
||||
TestService *service.TestService
|
||||
}
|
||||
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
var baseURL string
|
||||
if c.Request.TLS != nil {
|
||||
baseURL = "https://" + c.Request.Host
|
||||
} else {
|
||||
baseURL = "http://" + c.Request.Host
|
||||
func (tc *TestController) resetAndSeedHandler(ctx context.Context, input *testResetInput) (*httpapi.EmptyOutput, error) {
|
||||
request := httpapi.Request(ctx)
|
||||
scheme := "http"
|
||||
if request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
skipLdap := c.Query("skip-ldap") == "true"
|
||||
skipSeed := c.Query("skip-seed") == "true"
|
||||
baseURL := scheme + "://" + request.Host
|
||||
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetLock(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := tc.TestService.ResetLock(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := tc.TestService.ResetApplicationImages(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !skipSeed {
|
||||
if input.SkipSeed != "true" {
|
||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := tc.TestService.ResetAppConfig(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !skipLdap {
|
||||
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if input.SkipLDAP != "true" {
|
||||
if err := tc.TestService.SetLdapTestConfig(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := tc.TestService.SyncLdap(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
|
||||
func (tc *TestController) externalIDPJWKS(_ context.Context, _ *httpapi.EmptyInput) (*testBytesOutput, error) {
|
||||
jwks, err := tc.TestService.GetExternalIdPJWKS()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, jwks)
|
||||
body, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &testBytesOutput{ContentType: "application/json; charset=utf-8", Body: body}, nil
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Aud string `json:"aud"`
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
func (tc *TestController) externalIDPSignToken(_ context.Context, input *testExternalIDPInput) (*testBytesOutput, error) {
|
||||
token, err := tc.TestService.SignExternalIdPToken(input.Body.Issuer, input.Body.Subject, input.Body.Audience)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
|
||||
}
|
||||
|
||||
func (tc *TestController) signAccessToken(c *gin.Context) {
|
||||
var input struct {
|
||||
UserID string `json:"user"`
|
||||
ClientID string `json:"client"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
func (tc *TestController) signAccessToken(ctx context.Context, input *testAccessTokenInput) (*testBytesOutput, error) {
|
||||
token, err := tc.TestService.SignAccessToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.Expired)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
|
||||
}
|
||||
|
||||
func (tc *TestController) signRefreshToken(c *gin.Context) {
|
||||
var input struct {
|
||||
UserID string `json:"user"`
|
||||
ClientID string `json:"client"`
|
||||
RefreshToken string `json:"rt"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
func (tc *TestController) signRefreshToken(ctx context.Context, input *testRefreshTokenInput) (*testBytesOutput, error) {
|
||||
token, err := tc.TestService.SignRefreshToken(ctx, input.Body.UserID, input.Body.ClientID, input.Body.RefreshToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return &testBytesOutput{ContentType: "text/plain; charset=utf-8", Body: []byte(token)}, nil
|
||||
}
|
||||
|
||||
@@ -1,538 +1,435 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewOidcController creates a new controller for OIDC related endpoints
|
||||
// @Summary OIDC controller
|
||||
// @Description Initializes all OIDC-related API endpoints for authentication and client management
|
||||
// @Tags OIDC
|
||||
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
|
||||
oc := &OidcController{
|
||||
oidcService: oidcService,
|
||||
}
|
||||
type oidcClientIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
||||
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
||||
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
|
||||
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
|
||||
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
|
||||
type oidcClientListInput struct {
|
||||
utils.ListRequestOptions
|
||||
Search string `query:"search" required:"false"`
|
||||
}
|
||||
|
||||
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
|
||||
type oidcClientCreateInput struct {
|
||||
Body dto.OidcClientCreateDto
|
||||
}
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
|
||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||
type oidcClientUpdateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.OidcClientUpdateDto
|
||||
}
|
||||
|
||||
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||
type oidcAllowedGroupsInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.OidcUpdateAllowedUserGroupsDto
|
||||
}
|
||||
|
||||
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||
type oidcLogoInput struct {
|
||||
ID string `path:"id"`
|
||||
Light string `query:"light" default:"true" required:"false"`
|
||||
}
|
||||
|
||||
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
|
||||
type oidcLogoUploadInput struct {
|
||||
ID string `path:"id"`
|
||||
Light string `query:"light" default:"true" required:"false"`
|
||||
RawBody huma.MultipartFormFiles[imageUploadForm]
|
||||
}
|
||||
|
||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
||||
type oidcUserAuthorizedClientsInput struct {
|
||||
utils.ListRequestOptions
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
|
||||
type oidcOwnAuthorizedClientsInput struct {
|
||||
utils.ListRequestOptions
|
||||
}
|
||||
|
||||
type oidcClientAuthorizationInput struct {
|
||||
ClientID string `path:"clientId"`
|
||||
}
|
||||
|
||||
type oidcPreviewInput struct {
|
||||
ID string `path:"id"`
|
||||
UserID string `path:"userId"`
|
||||
Scopes string `query:"scopes" required:"true"`
|
||||
}
|
||||
|
||||
type oidcLogoOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
ContentLength int64 `header:"Content-Length"`
|
||||
CacheControl string `header:"Cache-Control"`
|
||||
Body func(huma.Context)
|
||||
}
|
||||
|
||||
// NewOidcController registers typed OIDC client management endpoints
|
||||
func NewOidcController(api huma.API, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
|
||||
controller := &OidcController{oidcService: oidcService}
|
||||
adminAuth := authMiddleware.Huma(api)
|
||||
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-oidc-clients",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients",
|
||||
Summary: "List OIDC clients",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.listClientsHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-oidc-client",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/clients",
|
||||
Summary: "Create OIDC client",
|
||||
Tags: []string{"OIDC"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, controller.createClientHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-client",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients/{id}",
|
||||
Summary: "Get OIDC client",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.getClientHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-client-metadata",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients/{id}/meta",
|
||||
Summary: "Get OIDC client metadata",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.getClientMetaDataHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-oidc-client",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/oidc/clients/{id}",
|
||||
Summary: "Update OIDC client",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.updateClientHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-oidc-client",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/oidc/clients/{id}",
|
||||
Summary: "Delete OIDC client",
|
||||
Tags: []string{"OIDC"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.deleteClientHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-oidc-client-allowed-user-groups",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/oidc/clients/{id}/allowed-user-groups",
|
||||
Summary: "Update allowed user groups",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.updateAllowedUserGroupsHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-oidc-client-secret",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/clients/{id}/secret",
|
||||
Summary: "Create client secret",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.createClientSecretHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-client-logo",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients/{id}/logo",
|
||||
Summary: "Get client logo",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.getClientLogoHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-oidc-client-logo",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/oidc/clients/{id}/logo",
|
||||
Summary: "Delete client logo",
|
||||
Tags: []string{"OIDC"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.deleteClientLogoHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-oidc-client-logo",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/clients/{id}/logo",
|
||||
Summary: "Update client logo",
|
||||
Tags: []string{"OIDC"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateClientLogoHandler, adminAuth, httpapi.WithMiddleware(fileSizeLimitMiddleware.Huma(api, 2<<20)))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "preview-oidc-client-data",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients/{id}/preview/{userId}",
|
||||
Summary: "Preview OIDC client data for user",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.getClientPreviewHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-own-authorized-oidc-clients",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/users/me/authorized-clients",
|
||||
Summary: "List authorized clients for current user",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.listOwnAuthorizedClientsHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-user-authorized-oidc-clients",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/users/{id}/authorized-clients",
|
||||
Summary: "List authorized clients for a user",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.listAuthorizedClientsHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "revoke-own-oidc-client-authorization",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/oidc/users/me/authorized-clients/{clientId}",
|
||||
Summary: "Revoke authorization for an OIDC client",
|
||||
Tags: []string{"OIDC"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.revokeOwnClientAuthorizationHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-own-accessible-oidc-clients",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/users/me/clients",
|
||||
Summary: "List accessible OIDC clients for current user",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.listOwnAccessibleClientsHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-client-scim-service-provider",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/clients/{id}/scim-service-provider",
|
||||
Summary: "Get SCIM service provider",
|
||||
Tags: []string{"OIDC"},
|
||||
}, controller.getClientScimServiceProviderHandler, adminAuth)
|
||||
}
|
||||
|
||||
type OidcController struct {
|
||||
oidcService *service.OidcService
|
||||
}
|
||||
|
||||
// getClientMetaDataHandler godoc
|
||||
// @Summary Get client metadata
|
||||
// @Description Get OIDC client metadata for discovery and configuration
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
|
||||
// @Router /api/oidc/clients/{id}/meta [get]
|
||||
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||
func (oc *OidcController) getClientMetaDataHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientMetaDataDto], error) {
|
||||
client, err := oc.oidcService.GetClient(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientDto := dto.OidcClientMetaDataDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
var output dto.OidcClientMetaDataDto
|
||||
if err := dto.MapStruct(client, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = c.Error(err)
|
||||
output.HasDarkLogo = client.HasDarkLogo()
|
||||
return &httpapi.BodyOutput[dto.OidcClientMetaDataDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// getClientHandler godoc
|
||||
// @Summary Get OIDC client
|
||||
// @Description Get detailed information about an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||
// @Router /api/oidc/clients/{id} [get]
|
||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||
func (oc *OidcController) getClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
|
||||
client, err := oc.oidcService.GetClient(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return mapOIDCClient(client)
|
||||
}
|
||||
|
||||
// listClientsHandler godoc
|
||||
// @Summary List OIDC clients
|
||||
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||
// @Tags OIDC
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
||||
// @Router /api/oidc/clients [get]
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
|
||||
func (oc *OidcController) listClientsHandler(ctx context.Context, input *oidcClientListInput) (*httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]], error) {
|
||||
clients, pagination, err := oc.oidcService.ListClients(ctx, input.Search, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map the user groups to DTOs
|
||||
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
|
||||
for i, client := range clients {
|
||||
var clientDto dto.OidcClientWithAllowedGroupsCountDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
output := make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
|
||||
for i := range clients {
|
||||
if err := dto.MapStruct(clients[i], &output[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
||||
output[i].HasDarkLogo = clients[i].HasDarkLogo()
|
||||
output[i].AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(ctx, clients[i].ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
clientsDto[i] = clientDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
|
||||
Data: clientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]]{Body: dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{Data: output, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// createClientHandler godoc
|
||||
// @Summary Create OIDC client
|
||||
// @Description Create a new OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||
// @Router /api/oidc/clients [post]
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
|
||||
func (oc *OidcController) createClientHandler(ctx context.Context, input *oidcClientCreateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
|
||||
client, err := oc.oidcService.CreateClient(ctx, input.Body, httpapi.UserID(ctx))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clientDto)
|
||||
return mapOIDCClient(client)
|
||||
}
|
||||
|
||||
// deleteClientHandler godoc
|
||||
// @Summary Delete OIDC client
|
||||
// @Description Delete an OIDC client by ID
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id} [delete]
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (oc *OidcController) deleteClientHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := oc.oidcService.DeleteClient(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updateClientHandler godoc
|
||||
// @Summary Update OIDC client
|
||||
// @Description Update an existing OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||
// @Router /api/oidc/clients/{id} [put]
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
|
||||
func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcClientUpdateInput) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
|
||||
client, err := oc.oidcService.UpdateClient(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return mapOIDCClient(client)
|
||||
}
|
||||
|
||||
// createClientSecretHandler godoc
|
||||
// @Summary Create client secret
|
||||
// @Description Generate a new secret for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||
// @Router /api/oidc/clients/{id}/secret [post]
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
|
||||
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
||||
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"secret": secret}}, nil
|
||||
}
|
||||
|
||||
// getClientLogoHandler godoc
|
||||
// @Summary Get client logo
|
||||
// @Description Get the logo image for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Produce image/svg+xml
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /api/oidc/clients/{id}/logo [get]
|
||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
|
||||
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
|
||||
func (oc *OidcController) getClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*oidcLogoOutput, error) {
|
||||
light, _ := strconv.ParseBool(input.Light)
|
||||
reader, size, mimeType, err := oc.oidcService.GetClientLogo(ctx, input.ID, light)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
|
||||
cacheControl := ""
|
||||
if !httpapi.QueryPresent(ctx, "skipCache") {
|
||||
cacheControl = utils.CacheControlValue(15*time.Minute, 12*time.Hour)
|
||||
}
|
||||
return &oidcLogoOutput{
|
||||
ContentType: mimeType,
|
||||
ContentLength: size,
|
||||
CacheControl: cacheControl,
|
||||
Body: func(streamCtx huma.Context) {
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(streamCtx.BodyWriter(), reader)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// updateClientLogoHandler godoc
|
||||
// @Summary Update client logo
|
||||
// @Description Upload or update the logo for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept multipart/form-data
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id}/logo [post]
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
func (oc *OidcController) updateClientLogoHandler(ctx context.Context, input *oidcLogoUploadInput) (*httpapi.EmptyOutput, error) {
|
||||
file, err := uploadFile(input.RawBody.Form)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
|
||||
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
light, _ := strconv.ParseBool(input.Light)
|
||||
if err := oc.oidcService.UpdateClientLogo(ctx, input.ID, file, light); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// deleteClientLogoHandler godoc
|
||||
// @Summary Delete client logo
|
||||
// @Description Delete the logo for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) deleteClientLogoHandler(ctx context.Context, input *oidcLogoInput) (*httpapi.EmptyOutput, error) {
|
||||
light, _ := strconv.ParseBool(input.Light)
|
||||
var err error
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
if lightLogo {
|
||||
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
|
||||
if light {
|
||||
err = oc.oidcService.DeleteClientLogo(ctx, input.ID)
|
||||
} else {
|
||||
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
|
||||
err = oc.oidcService.DeleteClientDarkLogo(ctx, input.ID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updateAllowedUserGroupsHandler godoc
|
||||
// @Summary Update allowed user groups
|
||||
// @Description Update the user groups allowed to access an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
|
||||
func (oc *OidcController) updateAllowedUserGroupsHandler(ctx context.Context, input *oidcAllowedGroupsInput) (*httpapi.BodyOutput[dto.OidcClientDto], error) {
|
||||
client, err := oc.oidcService.UpdateAllowedUserGroups(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var oidcClientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output dto.OidcClientDto
|
||||
if err := dto.MapStruct(client, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
|
||||
|
||||
c.JSON(http.StatusOK, oidcClientDto)
|
||||
output.HasDarkLogo = client.HasDarkLogo()
|
||||
return &httpapi.BodyOutput[dto.OidcClientDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// listOwnAuthorizedClientsHandler godoc
|
||||
// @Summary List authorized clients for current user
|
||||
// @Description Get a paginated list of OIDC clients that the current user has authorized
|
||||
// @Tags OIDC
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
|
||||
return oc.listAuthorizedClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
|
||||
}
|
||||
|
||||
// listAuthorizedClientsHandler godoc
|
||||
// @Summary List authorized clients for a user
|
||||
// @Description Get a paginated list of OIDC clients that a specific user has authorized
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "User ID"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(ctx context.Context, input *oidcUserAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
|
||||
return oc.listAuthorizedClients(ctx, input.ID, input.ListRequestOptions)
|
||||
}
|
||||
|
||||
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
|
||||
func (oc *OidcController) listAuthorizedClients(ctx context.Context, userID string, options utils.ListRequestOptions) (*httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]], error) {
|
||||
clients, pagination, err := oc.oidcService.ListAuthorizedClients(ctx, userID, options)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map the clients to DTOs
|
||||
var authorizedClientsDto []dto.AuthorizedOidcClientDto
|
||||
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []dto.AuthorizedOidcClientDto
|
||||
if err := dto.MapStructList(clients, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
|
||||
Data: authorizedClientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.AuthorizedOidcClientDto]]{Body: dto.Paginated[dto.AuthorizedOidcClientDto]{Data: output, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// revokeOwnClientAuthorizationHandler godoc
|
||||
// @Summary Revoke authorization for an OIDC client
|
||||
// @Description Revoke the authorization for a specific OIDC client for the current user
|
||||
// @Tags OIDC
|
||||
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
|
||||
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
func (oc *OidcController) revokeOwnClientAuthorizationHandler(ctx context.Context, input *oidcClientAuthorizationInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := oc.oidcService.RevokeAuthorizedClient(ctx, httpapi.UserID(ctx), input.ClientID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(ctx context.Context, input *oidcOwnAuthorizedClientsInput) (*httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]], error) {
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(ctx, httpapi.UserID(ctx), input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.AccessibleOidcClientDto]]{Body: dto.Paginated[dto.AccessibleOidcClientDto]{Data: clients, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// listOwnAccessibleClientsHandler godoc
|
||||
// @Summary List accessible OIDC clients for current user
|
||||
// @Description Get a list of OIDC clients that the current user can access
|
||||
// @Tags OIDC
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
|
||||
func (oc *OidcController) getClientPreviewHandler(ctx context.Context, input *oidcPreviewInput) (*httpapi.BodyOutput[dto.OidcClientPreviewDto], error) {
|
||||
if input.ID == "" {
|
||||
return nil, &common.ValidationError{Message: "client ID is required"}
|
||||
}
|
||||
if input.UserID == "" {
|
||||
return nil, &common.ValidationError{Message: "user ID is required"}
|
||||
}
|
||||
if input.Scopes == "" {
|
||||
return nil, &common.ValidationError{Message: "scopes are required"}
|
||||
}
|
||||
preview, err := oc.oidcService.GetClientPreview(ctx, input.ID, input.UserID, strings.Split(input.Scopes, " "), httpapi.AuthenticationMethod(ctx))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
|
||||
Data: clients,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.OidcClientPreviewDto]{Body: *preview}, nil
|
||||
}
|
||||
|
||||
// getClientPreviewHandler godoc
|
||||
// @Summary Preview OIDC client data for user
|
||||
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param userId path string true "User ID to preview data for"
|
||||
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
|
||||
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
userID := c.Param("userId")
|
||||
scopes := c.Query("scopes")
|
||||
|
||||
if clientID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if scopes == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := oc.oidcService.GetClientPreview(
|
||||
c.Request.Context(),
|
||||
clientID,
|
||||
userID,
|
||||
strings.Split(scopes, " "),
|
||||
c.GetString("authenticationMethod"))
|
||||
|
||||
func (oc *OidcController) getClientScimServiceProviderHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
|
||||
provider, err := oc.oidcService.GetClientScimServiceProvider(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, preview)
|
||||
var output dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
|
||||
}
|
||||
|
||||
// getClientScimServiceProviderHandler godoc
|
||||
// @Summary Get SCIM service provider
|
||||
// @Description Get the SCIM service provider configuration for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
|
||||
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
|
||||
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
|
||||
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func mapOIDCClient(client model.OidcClient) (*httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto], error) {
|
||||
var output dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var providerDto dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, providerDto)
|
||||
return &httpapi.BodyOutput[dto.OidcClientWithAllowedUserGroupsDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -1,122 +1,108 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
|
||||
ugc := ScimController{
|
||||
scimService: scimService,
|
||||
}
|
||||
type scimIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
|
||||
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
|
||||
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
|
||||
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
|
||||
type scimCreateInput struct {
|
||||
Body dto.ScimServiceProviderCreateDTO
|
||||
}
|
||||
|
||||
type scimUpdateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.ScimServiceProviderCreateDTO
|
||||
}
|
||||
|
||||
func NewScimController(api huma.API, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
|
||||
controller := &ScimController{scimService: scimService}
|
||||
auth := authMiddleware.Huma(api)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-scim-service-provider",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/scim/service-provider",
|
||||
Summary: "Create SCIM service provider",
|
||||
Tags: []string{"SCIM"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, controller.createServiceProviderHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "sync-scim-service-provider",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/scim/service-provider/{id}/sync",
|
||||
Summary: "Sync SCIM service provider",
|
||||
Tags: []string{"SCIM"},
|
||||
DefaultStatus: http.StatusOK,
|
||||
}, controller.syncServiceProviderHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-scim-service-provider",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/scim/service-provider/{id}",
|
||||
Summary: "Update SCIM service provider",
|
||||
Tags: []string{"SCIM"},
|
||||
}, controller.updateServiceProviderHandler, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-scim-service-provider",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/scim/service-provider/{id}",
|
||||
Summary: "Delete SCIM service provider",
|
||||
Tags: []string{"SCIM"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.deleteServiceProviderHandler, auth)
|
||||
}
|
||||
|
||||
type ScimController struct {
|
||||
scimService *service.ScimService
|
||||
}
|
||||
|
||||
// syncServiceProviderHandler godoc
|
||||
// @Summary Sync SCIM service provider
|
||||
// @Description Trigger synchronization for a SCIM service provider
|
||||
// @Tags SCIM
|
||||
// @Param id path string true "Service Provider ID"
|
||||
// @Success 200 "OK"
|
||||
// @Router /api/scim/service-provider/{id}/sync [post]
|
||||
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
|
||||
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
func (c *ScimController) syncServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := c.scimService.SyncServiceProvider(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// createServiceProviderHandler godoc
|
||||
// @Summary Create SCIM service provider
|
||||
// @Description Create a new SCIM service provider
|
||||
// @Tags SCIM
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
|
||||
// @Router /api/scim/service-provider [post]
|
||||
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
|
||||
var input dto.ScimServiceProviderCreateDTO
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
|
||||
func (c *ScimController) createServiceProviderHandler(ctx context.Context, input *scimCreateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
|
||||
provider, err := c.scimService.CreateServiceProvider(ctx, &input.Body)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var providerDTO dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, providerDTO)
|
||||
return mapSCIMProvider(provider)
|
||||
}
|
||||
|
||||
// updateServiceProviderHandler godoc
|
||||
// @Summary Update SCIM service provider
|
||||
// @Description Update an existing SCIM service provider
|
||||
// @Tags SCIM
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Service Provider ID"
|
||||
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
|
||||
// @Router /api/scim/service-provider/{id} [put]
|
||||
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
|
||||
var input dto.ScimServiceProviderCreateDTO
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
|
||||
func (c *ScimController) updateServiceProviderHandler(ctx context.Context, input *scimUpdateInput) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
|
||||
provider, err := c.scimService.UpdateServiceProvider(ctx, input.ID, &input.Body)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var providerDTO dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, providerDTO)
|
||||
return mapSCIMProvider(provider)
|
||||
}
|
||||
|
||||
// deleteServiceProviderHandler godoc
|
||||
// @Summary Delete SCIM service provider
|
||||
// @Description Delete a SCIM service provider by ID
|
||||
// @Tags SCIM
|
||||
// @Param id path string true "Service Provider ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/scim/service-provider/{id} [delete]
|
||||
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
|
||||
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
func (c *ScimController) deleteServiceProviderHandler(ctx context.Context, input *scimIDInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := c.scimService.DeleteServiceProvider(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func mapSCIMProvider(provider any) (*httpapi.BodyOutput[dto.ScimServiceProviderDTO], error) {
|
||||
var output dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[dto.ScimServiceProviderDTO]{Body: output}, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,250 +1,185 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewUserGroupController creates a new controller for user group management
|
||||
// @Summary User group management controller
|
||||
// @Description Initializes all user group-related API endpoints
|
||||
// @Tags User Groups
|
||||
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
|
||||
ugc := UserGroupController{
|
||||
UserGroupService: userGroupService,
|
||||
}
|
||||
type userGroupListInput struct {
|
||||
utils.ListRequestOptions
|
||||
Search string `query:"search" required:"false"`
|
||||
}
|
||||
|
||||
userGroupsGroup := group.Group("/user-groups")
|
||||
userGroupsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
userGroupsGroup.GET("", ugc.list)
|
||||
userGroupsGroup.GET("/:id", ugc.get)
|
||||
userGroupsGroup.POST("", ugc.create)
|
||||
userGroupsGroup.PUT("/:id", ugc.update)
|
||||
userGroupsGroup.DELETE("/:id", ugc.delete)
|
||||
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
|
||||
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
|
||||
}
|
||||
type userGroupIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type userGroupCreateInput struct {
|
||||
Body dto.UserGroupCreateDto
|
||||
}
|
||||
|
||||
type userGroupUpdateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.UserGroupCreateDto
|
||||
}
|
||||
|
||||
type userGroupUsersInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.UserGroupUpdateUsersDto
|
||||
}
|
||||
|
||||
type userGroupClientsInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.UserGroupUpdateAllowedOidcClientsDto
|
||||
}
|
||||
|
||||
// NewUserGroupController registers user group management routes
|
||||
func NewUserGroupController(api huma.API, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
|
||||
controller := &UserGroupController{UserGroupService: userGroupService}
|
||||
auth := authMiddleware.Huma(api)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-user-groups",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/user-groups",
|
||||
Summary: "List user groups",
|
||||
Tags: []string{"User Groups"},
|
||||
}, controller.list, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-user-group",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/user-groups/{id}",
|
||||
Summary: "Get user group by ID",
|
||||
Tags: []string{"User Groups"},
|
||||
}, controller.get, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-user-group",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user-groups",
|
||||
Summary: "Create user group",
|
||||
Tags: []string{"User Groups"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, controller.create, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-user-group",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/user-groups/{id}",
|
||||
Summary: "Update user group",
|
||||
Tags: []string{"User Groups"},
|
||||
}, controller.update, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-user-group",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/user-groups/{id}",
|
||||
Summary: "Delete user group",
|
||||
Tags: []string{"User Groups"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.delete, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-user-group-users",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/user-groups/{id}/users",
|
||||
Summary: "Update users in a group",
|
||||
Tags: []string{"User Groups"},
|
||||
}, controller.updateUsers, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "update-user-group-allowed-oidc-clients",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/user-groups/{id}/allowed-oidc-clients",
|
||||
Summary: "Update allowed OIDC clients",
|
||||
Tags: []string{"User Groups"},
|
||||
}, controller.updateAllowedOIDCClients, auth)
|
||||
}
|
||||
|
||||
type UserGroupController struct {
|
||||
UserGroupService *service.UserGroupService
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List user groups
|
||||
// @Description Get a paginated list of user groups with optional search and sorting
|
||||
// @Tags User Groups
|
||||
// @Param search query string false "Search term to filter user groups by name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
|
||||
// @Router /api/user-groups [get]
|
||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
|
||||
func (ugc *UserGroupController) list(ctx context.Context, input *userGroupListInput) (*httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]], error) {
|
||||
groups, pagination, err := ugc.UserGroupService.List(ctx, input.Search, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map the user groups to DTOs
|
||||
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
|
||||
groupsDTO := make([]dto.UserGroupMinimalDto, len(groups))
|
||||
for i, group := range groups {
|
||||
var groupDto dto.UserGroupMinimalDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := dto.MapStruct(group, &groupsDTO[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
|
||||
groupsDTO[i].UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
groupsDto[i] = groupDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
|
||||
Data: groupsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[dto.UserGroupMinimalDto]]{Body: dto.Paginated[dto.UserGroupMinimalDto]{Data: groupsDTO, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get user group by ID
|
||||
// @Description Retrieve detailed information about a specific user group including its users
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 200 {object} dto.UserGroupDto
|
||||
// @Router /api/user-groups/{id} [get]
|
||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
||||
func (ugc *UserGroupController) get(ctx context.Context, input *userGroupIDInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
group, err := ugc.UserGroupService.Get(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return mapUserGroup(group)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create user group
|
||||
// @Description Create a new user group
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 201 {object} dto.UserGroupDto "Created user group"
|
||||
// @Router /api/user-groups [post]
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
|
||||
func (ugc *UserGroupController) create(ctx context.Context, input *userGroupCreateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
group, err := ugc.UserGroupService.Create(ctx, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, groupDto)
|
||||
return mapUserGroup(group)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update user group
|
||||
// @Description Update an existing user group by ID
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 200 {object} dto.UserGroupDto "Updated user group"
|
||||
// @Router /api/user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
|
||||
func (ugc *UserGroupController) update(ctx context.Context, input *userGroupUpdateInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
group, err := ugc.UserGroupService.Update(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return mapUserGroup(group)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete user group
|
||||
// @Description Delete a specific user group by ID
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/user-groups/{id} [delete]
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (ugc *UserGroupController) delete(ctx context.Context, input *userGroupIDInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := ugc.UserGroupService.Delete(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updateUsers godoc
|
||||
// @Summary Update users in a group
|
||||
// @Description Update the list of users belonging to a specific user group
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||
// @Success 200 {object} dto.UserGroupDto
|
||||
// @Router /api/user-groups/{id}/users [put]
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
|
||||
func (ugc *UserGroupController) updateUsers(ctx context.Context, input *userGroupUsersInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
group, err := ugc.UserGroupService.UpdateUsers(ctx, input.ID, input.Body.UserIDs)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return mapUserGroup(group)
|
||||
}
|
||||
|
||||
// updateAllowedOidcClients godoc
|
||||
// @Summary Update allowed OIDC clients
|
||||
// @Description Update the OIDC clients allowed for a specific user group
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
|
||||
// @Success 200 {object} dto.UserGroupDto "Updated user group"
|
||||
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
|
||||
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
|
||||
var input dto.UserGroupUpdateAllowedOidcClientsDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
|
||||
func (ugc *UserGroupController) updateAllowedOIDCClients(ctx context.Context, input *userGroupClientsInput) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
group, err := ugc.UserGroupService.UpdateAllowedOidcClient(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userGroupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userGroupDto)
|
||||
return mapUserGroup(group)
|
||||
}
|
||||
|
||||
func mapUserGroup(group any) (*httpapi.BodyOutput[dto.UserGroupDto], error) {
|
||||
var output dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[dto.UserGroupDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -1,56 +1,63 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewVersionController registers version-related routes.
|
||||
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
|
||||
type versionOutput struct {
|
||||
CacheControl string `header:"Cache-Control"`
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
// NewVersionController registers version-related routes
|
||||
func NewVersionController(api huma.API, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
|
||||
vc := &VersionController{versionService: versionService}
|
||||
group.GET("/version/latest", vc.getLatestVersionHandler)
|
||||
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
|
||||
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-latest-version",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/version/latest",
|
||||
Summary: "Get latest available version of Pocket ID",
|
||||
Tags: []string{"Version"},
|
||||
}, vc.getLatestVersionHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-current-version",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/version/current",
|
||||
Summary: "Get current deployed version of Pocket ID",
|
||||
Tags: []string{"Version"},
|
||||
}, vc.getCurrentVersionHandler, userAuth)
|
||||
}
|
||||
|
||||
type VersionController struct {
|
||||
versionService *service.VersionService
|
||||
}
|
||||
|
||||
// getLatestVersionHandler godoc
|
||||
// @Summary Get latest available version of Pocket ID
|
||||
// @Tags Version
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Latest version information"
|
||||
// @Router /api/version/latest [get]
|
||||
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
|
||||
func (vc *VersionController) getLatestVersionHandler(ctx context.Context, _ *httpapi.EmptyInput) (*versionOutput, error) {
|
||||
tag, err := vc.versionService.GetLatestVersion(ctx)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"latestVersion": tag,
|
||||
})
|
||||
cacheControl := ""
|
||||
if !httpapi.QueryPresent(ctx, "skipCache") {
|
||||
cacheControl = utils.CacheControlValue(5*time.Minute, 15*time.Minute)
|
||||
}
|
||||
return &versionOutput{CacheControl: cacheControl, Body: map[string]string{"latestVersion": tag}}, nil
|
||||
}
|
||||
|
||||
// getCurrentVersionHandler godoc
|
||||
// @Summary Get current deployed version of Pocket ID
|
||||
// @Tags Version
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Current version information"
|
||||
// @Router /api/version/current [get]
|
||||
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"currentVersion": common.Version,
|
||||
})
|
||||
func (vc *VersionController) getCurrentVersionHandler(_ context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"currentVersion": common.Version}}, nil
|
||||
}
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// NewWellKnownController creates a new controller for OIDC discovery endpoints
|
||||
// @Summary OIDC Discovery controller
|
||||
// @Description Initializes OIDC discovery and JWKS endpoints
|
||||
// @Tags Well Known
|
||||
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
|
||||
wkc := &WellKnownController{jwtService: jwtService}
|
||||
type wellKnownOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// NewWellKnownController registers OIDC discovery endpoints
|
||||
func NewWellKnownController(api huma.API, jwtService *service.JwtService) {
|
||||
controller := &WellKnownController{jwtService: jwtService}
|
||||
|
||||
// Pre-compute the OIDC configuration document, which is static
|
||||
var err error
|
||||
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
||||
controller.oidcConfig, err = controller.computeOIDCConfiguration()
|
||||
if err != nil {
|
||||
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-jwks",
|
||||
Method: http.MethodGet,
|
||||
Path: "/.well-known/jwks.json",
|
||||
Summary: "Get JSON Web Key Set",
|
||||
Tags: []string{"Well Known"},
|
||||
}, controller.jwksHandler)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-openid-configuration",
|
||||
Method: http.MethodGet,
|
||||
Path: "/.well-known/openid-configuration",
|
||||
Summary: "Get OpenID Connect discovery configuration",
|
||||
Tags: []string{"Well Known"},
|
||||
}, controller.openIDConfigurationHandler)
|
||||
}
|
||||
|
||||
type WellKnownController struct {
|
||||
@@ -38,51 +54,35 @@ type WellKnownController struct {
|
||||
oidcConfig []byte
|
||||
}
|
||||
|
||||
// jwksHandler godoc
|
||||
// @Summary Get JSON Web Key Set (JWKS)
|
||||
// @Description Returns the JSON Web Key Set used for token verification
|
||||
// @Tags Well Known
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "{ \"keys\": []interface{} }"
|
||||
// @Router /.well-known/jwks.json [get]
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
func (wkc *WellKnownController) jwksHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
|
||||
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
|
||||
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: jwks}, nil
|
||||
}
|
||||
|
||||
// openIDConfigurationHandler godoc
|
||||
// @Summary Get OpenID Connect discovery configuration
|
||||
// @Description Returns the OpenID Connect discovery document with endpoints and capabilities
|
||||
// @Tags Well Known
|
||||
// @Success 200 {object} object "OpenID Connect configuration"
|
||||
// @Router /.well-known/openid-configuration [get]
|
||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
|
||||
func (wkc *WellKnownController) openIDConfigurationHandler(_ context.Context, _ *httpapi.EmptyInput) (*wellKnownOutput, error) {
|
||||
return &wellKnownOutput{ContentType: "application/json; charset=utf-8", Body: wkc.oidcConfig}, nil
|
||||
}
|
||||
|
||||
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
appUrl := common.EnvConfig.AppURL
|
||||
|
||||
internalAppUrl := common.EnvConfig.InternalAppURL
|
||||
appURL := common.EnvConfig.AppURL
|
||||
internalAppURL := common.EnvConfig.InternalAppURL
|
||||
|
||||
alg, err := wkc.jwtService.GetKeyAlg()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||
}
|
||||
config := map[string]any{
|
||||
"issuer": appUrl,
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": internalAppUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
|
||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
|
||||
"issuer": appURL,
|
||||
"authorization_endpoint": appURL + "/authorize",
|
||||
"token_endpoint": internalAppURL + "/api/oidc/token",
|
||||
"userinfo_endpoint": internalAppURL + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appURL + "/api/oidc/end-session",
|
||||
"introspection_endpoint": internalAppURL + "/api/oidc/introspect",
|
||||
"device_authorization_endpoint": appURL + "/api/oidc/device/authorize",
|
||||
"jwks_uri": internalAppURL + "/.well-known/jwks.json",
|
||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"},
|
||||
@@ -96,7 +96,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
"request_object_signing_alg_values_supported": []string{"none"},
|
||||
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
|
||||
"pushed_authorization_request_endpoint": internalAppURL + "/api/oidc/par",
|
||||
"require_pushed_authorization_requests": false,
|
||||
}
|
||||
return json.Marshal(config)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/mail"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type PublicAppConfigVariableDto struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
@@ -12,47 +19,64 @@ type AppConfigVariableDto struct {
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
HomePageURL string `json:"homePageUrl" binding:"required"`
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||
LdapUrl string `json:"ldapUrl"`
|
||||
LdapBindDn string `json:"ldapBindDn"`
|
||||
LdapBindPassword string `json:"ldapBindPassword"`
|
||||
LdapBase string `json:"ldapBase"`
|
||||
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName string `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
|
||||
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
|
||||
AppName string `json:"appName" required:"false" minLength:"1" maxLength:"30" unorm:"nfc"`
|
||||
SessionDuration string `json:"sessionDuration" required:"false"`
|
||||
HomePageURL string `json:"homePageUrl" required:"false"`
|
||||
EmailsVerified string `json:"emailsVerified" required:"false"`
|
||||
DisableAnimations string `json:"disableAnimations" required:"false"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" required:"false"`
|
||||
AllowUserSignups string `json:"allowUserSignups" required:"false" enum:"disabled,withToken,open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" required:"false"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" required:"false"`
|
||||
AccentColor string `json:"accentColor" required:"false"`
|
||||
RequireUserEmail string `json:"requireUserEmail" required:"false"`
|
||||
SmtpHost string `json:"smtpHost" required:"false"`
|
||||
SmtpPort string `json:"smtpPort" required:"false"`
|
||||
SmtpFrom string `json:"smtpFrom" required:"false"`
|
||||
SmtpUser string `json:"smtpUser" required:"false"`
|
||||
SmtpPassword string `json:"smtpPassword" required:"false"`
|
||||
SmtpTls string `json:"smtpTls" required:"false" enum:"none,starttls,tls"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify" required:"false"`
|
||||
LdapEnabled string `json:"ldapEnabled" required:"false"`
|
||||
LdapUrl string `json:"ldapUrl" required:"false"`
|
||||
LdapBindDn string `json:"ldapBindDn" required:"false"`
|
||||
LdapBindPassword string `json:"ldapBindPassword" required:"false"`
|
||||
LdapBase string `json:"ldapBase" required:"false"`
|
||||
LdapUserSearchFilter string `json:"ldapUserSearchFilter" required:"false"`
|
||||
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter" required:"false"`
|
||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify" required:"false"`
|
||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier" required:"false"`
|
||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername" required:"false"`
|
||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail" required:"false"`
|
||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName" required:"false"`
|
||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName" required:"false"`
|
||||
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName" required:"false"`
|
||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture" required:"false"`
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember" required:"false"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier" required:"false"`
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName" required:"false"`
|
||||
LdapAdminGroupName string `json:"ldapAdminGroupName" required:"false"`
|
||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" required:"false"`
|
||||
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" required:"false"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" required:"false"`
|
||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" required:"false"`
|
||||
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" required:"false"`
|
||||
EmailVerificationEnabled string `json:"emailVerificationEnabled" required:"false"`
|
||||
}
|
||||
|
||||
func (d *AppConfigUpdateDto) Resolve(huma.Context) []error {
|
||||
var errs []error
|
||||
if d.SmtpFrom != "" {
|
||||
address, err := mail.ParseAddress(d.SmtpFrom)
|
||||
if err != nil || address.Address != d.SmtpFrom {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.smtpFrom", Message: "Field validation for 'SmtpFrom' failed on the 'email' tag"})
|
||||
}
|
||||
}
|
||||
if d.SignupDefaultUserGroupIDs != "" && !json.Valid([]byte(d.SignupDefaultUserGroupIDs)) {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultUserGroupIDs", Message: "Signup default user group IDs must be valid JSON"})
|
||||
}
|
||||
if d.SignupDefaultCustomClaims != "" && !json.Valid([]byte(d.SignupDefaultCustomClaims)) {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.signupDefaultCustomClaims", Message: "Signup default custom claims must be valid JSON"})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||
Key string `json:"key" required:"true" unorm:"nfc"`
|
||||
Value string `json:"value" required:"true" unorm:"nfc"`
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package dto
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
@@ -68,7 +66,3 @@ loop:
|
||||
fv.SetString(val)
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
|
||||
return ctx.ShouldBindWith(obj, binding.JSON)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package dto
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
@@ -36,27 +39,27 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
||||
}
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
|
||||
SkipConsent bool `json:"skipConsent"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
Name string `json:"name" required:"true" maxLength:"50" unorm:"nfc"`
|
||||
Description string `json:"description" required:"false" maxLength:"150" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" required:"false"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" required:"false"`
|
||||
IsPublic bool `json:"isPublic" required:"false"`
|
||||
PkceEnabled bool `json:"pkceEnabled" required:"false"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication" required:"false"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests" required:"false"`
|
||||
SkipConsent bool `json:"skipConsent" required:"false"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials" required:"false"`
|
||||
LaunchURL *string `json:"launchURL" required:"false" format:"uri"`
|
||||
HasLogo bool `json:"hasLogo" required:"false"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo" required:"false"`
|
||||
LogoURL *string `json:"logoUrl" required:"false"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl" required:"false"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted" required:"false"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
OidcClientUpdateDto
|
||||
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
|
||||
ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"`
|
||||
}
|
||||
|
||||
type OidcClientCredentialsDto struct {
|
||||
@@ -64,15 +67,42 @@ type OidcClientCredentialsDto struct {
|
||||
}
|
||||
|
||||
type OidcClientFederatedIdentityDto struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Issuer string `json:"issuer" required:"false"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
ReplayProtection bool `json:"replayProtection"`
|
||||
ReplayProtection bool `json:"replayProtection" required:"false"`
|
||||
}
|
||||
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
||||
UserGroupIDs []string `json:"userGroupIds" required:"true"`
|
||||
}
|
||||
|
||||
func (d *OidcClientUpdateDto) Resolve(huma.Context) []error {
|
||||
return validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs)
|
||||
}
|
||||
|
||||
func (d *OidcClientCreateDto) Resolve(huma.Context) []error {
|
||||
errs := validateCallbackURLLists(d.CallbackURLs, d.LogoutCallbackURLs)
|
||||
if d.ID != "" && !ValidateClientID(d.ID) {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.id", Message: "Client ID is invalid"})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCallbackURLLists(callbackURLs, logoutCallbackURLs []string) []error {
|
||||
var errs []error
|
||||
for _, callbackURL := range callbackURLs {
|
||||
if !ValidateCallbackURLPattern(callbackURL) {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.callbackURLs", Message: "Callback URL pattern is invalid"})
|
||||
}
|
||||
}
|
||||
for _, callbackURL := range logoutCallbackURLs {
|
||||
if !ValidateCallbackURLPattern(callbackURL) {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.logoutCallbackURLs", Message: "Logout callback URL pattern is invalid"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
type OidcLogoutDto struct {
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
package dto
|
||||
|
||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
TTL utils.JSONDuration `json:"ttl" required:"false"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
Email string `json:"email" required:"true" format:"email" unorm:"nfc"`
|
||||
RedirectPath string `json:"redirectPath" required:"false"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsAdminDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
TTL utils.JSONDuration `json:"ttl" required:"false"`
|
||||
}
|
||||
|
||||
func (d *OneTimeAccessTokenCreateDto) Resolve(huma.Context) []error {
|
||||
return resolveTTL(d.TTL)
|
||||
}
|
||||
|
||||
func (d *OneTimeAccessEmailAsAdminDto) Resolve(huma.Context) []error {
|
||||
return resolveTTL(d.TTL)
|
||||
}
|
||||
|
||||
func resolveTTL(ttl utils.JSONDuration) []error {
|
||||
if ValidateTTL(ttl) {
|
||||
return nil
|
||||
}
|
||||
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -16,9 +18,17 @@ type ScimServiceProviderDTO struct {
|
||||
}
|
||||
|
||||
type ScimServiceProviderCreateDTO struct {
|
||||
Endpoint string `json:"endpoint" binding:"required,url"`
|
||||
Token string `json:"token"`
|
||||
OidcClientID string `json:"oidcClientId" binding:"required"`
|
||||
Endpoint string `json:"endpoint" required:"true" format:"uri"`
|
||||
Token string `json:"token" required:"false"`
|
||||
OidcClientID string `json:"oidcClientId" required:"true"`
|
||||
}
|
||||
|
||||
func (d *ScimServiceProviderCreateDTO) Resolve(huma.Context) []error {
|
||||
endpoint, err := url.Parse(d.Endpoint)
|
||||
if err != nil || !endpoint.IsAbs() {
|
||||
return []error{&huma.ErrorDetail{Location: "body.endpoint", Message: "Endpoint must be an absolute URI"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ScimUser struct {
|
||||
|
||||
@@ -2,8 +2,10 @@ package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
@@ -23,34 +25,57 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
|
||||
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified" required:"false"`
|
||||
FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" required:"false" maxLength:"100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin" required:"false"`
|
||||
Locale *string `json:"locale" required:"false"`
|
||||
Disabled bool `json:"disabled" required:"false"`
|
||||
UserGroupIds []string `json:"userGroupIds" required:"false"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
e, ok := binding.Validator.Engine().(interface {
|
||||
Struct(s any) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("validator does not implement the expected interface")
|
||||
func (u UserCreateDto) Resolve(huma.Context) []error {
|
||||
if u.Email == nil {
|
||||
return nil
|
||||
}
|
||||
address, err := mail.ParseAddress(*u.Email)
|
||||
if err != nil || address.Address != *u.Email {
|
||||
return []error{&huma.ErrorDetail{Location: "body.email", Message: "Field validation for 'Email' failed on the 'email' tag"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.Struct(u)
|
||||
//nolint:staticcheck // LDAP callers and their tests rely on the existing capitalized validation text
|
||||
func (u UserCreateDto) Validate() error {
|
||||
if u.Username == "" {
|
||||
return errors.New("Field validation for 'Username' failed on the 'required' tag")
|
||||
}
|
||||
if !ValidateUsername(u.Username) {
|
||||
return errors.New("Field validation for 'Username' failed on the 'username' tag")
|
||||
}
|
||||
if utf8.RuneCountInString(u.Username) > 50 {
|
||||
return errors.New("Field validation for 'Username' failed on the 'max' tag")
|
||||
}
|
||||
if utf8.RuneCountInString(u.FirstName) > 50 {
|
||||
return errors.New("Field validation for 'FirstName' failed on the 'max' tag")
|
||||
}
|
||||
if utf8.RuneCountInString(u.LastName) > 50 {
|
||||
return errors.New("Field validation for 'LastName' failed on the 'max' tag")
|
||||
}
|
||||
if utf8.RuneCountInString(u.DisplayName) > 100 {
|
||||
return errors.New("Field validation for 'DisplayName' failed on the 'max' tag")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EmailVerificationDto struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Token string `json:"token" required:"true"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
UserGroupIds []string `json:"userGroupIds" required:"true"`
|
||||
}
|
||||
|
||||
@@ -63,17 +63,6 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: new("not-an-email"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
||||
},
|
||||
{
|
||||
name: "first name too short",
|
||||
input: UserCreateDto{
|
||||
@@ -112,3 +101,28 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreateDtoResolveEmail(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
email *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty email", email: nil},
|
||||
{name: "exact address", email: new("test@example.com")},
|
||||
{name: "invalid address", email: new("not-an-email"), wantErr: true},
|
||||
{name: "display name is not an exact address", email: new("Test User <test@example.com>"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
errs := (&UserCreateDto{Email: testCase.email}).Resolve(nil)
|
||||
if testCase.wantErr {
|
||||
require.Len(t, errs, 1)
|
||||
require.ErrorContains(t, errs[0], "Field validation for 'Email' failed on the 'email' tag")
|
||||
return
|
||||
}
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -29,26 +29,27 @@ type UserGroupMinimalDto struct {
|
||||
}
|
||||
|
||||
type UserGroupUpdateAllowedOidcClientsDto struct {
|
||||
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
|
||||
OidcClientIDs []string `json:"oidcClientIds" required:"true"`
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||
FriendlyName string `json:"friendlyName" required:"true" minLength:"2" maxLength:"50" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"2" maxLength:"255" unorm:"nfc"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (g UserGroupCreateDto) Validate() error {
|
||||
e, ok := binding.Validator.Engine().(interface {
|
||||
Struct(s any) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("validator does not implement the expected interface")
|
||||
friendlyNameLength := utf8.RuneCountInString(g.FriendlyName)
|
||||
if friendlyNameLength < 2 || friendlyNameLength > 50 {
|
||||
return errors.New("friendly name is invalid")
|
||||
}
|
||||
|
||||
return e.Struct(g)
|
||||
nameLength := utf8.RuneCountInString(g.Name)
|
||||
if nameLength < 2 || nameLength > 255 {
|
||||
return errors.New("name is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
UserIDs []string `json:"userIds" required:"true"`
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ import (
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||
@@ -21,44 +18,7 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
|
||||
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
func init() {
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
// Maximum allowed value for TTLs
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
validators := map[string]validator.Func{
|
||||
"username": func(fl validator.FieldLevel) bool {
|
||||
return ValidateUsername(fl.Field().String())
|
||||
},
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Allow zero, which means the field wasn't set
|
||||
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||
},
|
||||
"callback_url": func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURL(fl.Field().String())
|
||||
},
|
||||
"callback_url_pattern": func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURLPattern(fl.Field().String())
|
||||
},
|
||||
"resource_uri": func(fl validator.FieldLevel) bool {
|
||||
return ValidateResourceURI(fl.Field().String())
|
||||
},
|
||||
}
|
||||
for k, v := range validators {
|
||||
err := engine.RegisterValidation(k, v)
|
||||
if err != nil {
|
||||
panic("Failed to register custom validation for " + k + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
// ValidateUsername validates username inputs
|
||||
func ValidateUsername(username string) bool {
|
||||
@@ -70,6 +30,11 @@ func ValidateClientID(clientID string) bool {
|
||||
return validateClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateTTL validates optional API durations against the existing bounds
|
||||
func ValidateTTL(ttl utils.JSONDuration) bool {
|
||||
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||
}
|
||||
|
||||
// isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link
|
||||
func isActiveContentScheme(scheme string) bool {
|
||||
switch strings.ToLower(scheme) {
|
||||
|
||||
@@ -10,7 +10,7 @@ type WebauthnCredentialDto struct {
|
||||
Name string `json:"name"`
|
||||
CredentialID string `json:"credentialID"`
|
||||
AttestationType string `json:"attestationType"`
|
||||
Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"`
|
||||
Transport []protocol.AuthenticatorTransport `json:"transport"`
|
||||
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50"`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -2,11 +2,15 @@ package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
|
||||
@@ -62,7 +66,7 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth.
|
||||
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth
|
||||
func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
|
||||
clone := &AuthMiddleware{
|
||||
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||
@@ -75,60 +79,108 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
|
||||
|
||||
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Set("authenticationMethod", authenticationMethod)
|
||||
c.Set("authenticationTime", authenticationTime)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// If JWT auth failed and the error is not a NotSignedInError, abort the request
|
||||
if !errors.Is(err, &common.NotSignedInError{}) {
|
||||
result, err := m.authenticate(c)
|
||||
if err != nil {
|
||||
c.Abort()
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !m.options.AllowApiKeyAuth {
|
||||
if m.options.SuccessOptional {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Abort()
|
||||
if c.GetHeader("X-API-Key") != "" {
|
||||
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if result.UserID != "" {
|
||||
setGinAuthentication(c, result)
|
||||
}
|
||||
|
||||
// JWT auth failed, try API key auth
|
||||
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if m.options.SuccessOptional {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Both JWT and API key auth failed
|
||||
c.Abort()
|
||||
_ = c.Error(err)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Huma returns an operation decorator using the same authentication behavior as Add
|
||||
func (m *AuthMiddleware) Huma(api huma.API) func(*huma.Operation) {
|
||||
return func(operation *huma.Operation) {
|
||||
operation.Security = m.securityRequirements()
|
||||
if m.options.AdminRequired {
|
||||
if operation.Extensions == nil {
|
||||
operation.Extensions = map[string]any{}
|
||||
}
|
||||
operation.Extensions["x-pocket-id-admin-required"] = true
|
||||
}
|
||||
operation.Middlewares = append(operation.Middlewares, func(ctx huma.Context, next func(huma.Context)) {
|
||||
result, err := m.authenticate(humagin.Unwrap(ctx))
|
||||
if err != nil {
|
||||
status := 500
|
||||
message := "Something went wrong"
|
||||
var appError common.AppError
|
||||
if errors.As(err, &appError) {
|
||||
status = appError.HttpStatusCode()
|
||||
message = appError.Error()
|
||||
}
|
||||
_ = huma.WriteErr(api, ctx, status, message)
|
||||
return
|
||||
}
|
||||
if result.UserID != "" {
|
||||
ctx = httpapi.WithAuthentication(ctx, result.UserID, result.IsAdmin, result.AuthenticationMethod, result.AuthenticationTime)
|
||||
}
|
||||
next(ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type authenticationResult struct {
|
||||
UserID string
|
||||
IsAdmin bool
|
||||
AuthenticationMethod string
|
||||
AuthenticationTime time.Time
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) authenticate(c *gin.Context) (authenticationResult, error) {
|
||||
// Return immediately after successful JWT authentication
|
||||
userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
return authenticationResult{userID, isAdmin, authenticationMethod, authenticationTime}, nil
|
||||
}
|
||||
|
||||
// Fall back only when JWT verification reports that the request is not signed in
|
||||
if !errors.Is(err, &common.NotSignedInError{}) {
|
||||
return authenticationResult{}, err
|
||||
}
|
||||
|
||||
// Handle API-key-disabled routes before considering API-key authentication
|
||||
if !m.options.AllowApiKeyAuth {
|
||||
if m.options.SuccessOptional {
|
||||
return authenticationResult{}, nil
|
||||
}
|
||||
if c.GetHeader("X-API-Key") != "" {
|
||||
return authenticationResult{}, &common.APIKeyAuthNotAllowedError{}
|
||||
}
|
||||
return authenticationResult{}, err
|
||||
}
|
||||
|
||||
// Attempt API-key authentication after JWT reports that the request is not signed in
|
||||
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
return authenticationResult{UserID: userID, IsAdmin: isAdmin}, nil
|
||||
}
|
||||
if m.options.SuccessOptional {
|
||||
return authenticationResult{}, nil
|
||||
}
|
||||
return authenticationResult{}, err
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) securityRequirements() []map[string][]string {
|
||||
requirements := []map[string][]string{
|
||||
{"BearerAuth": {}},
|
||||
{"SessionCookie": {}},
|
||||
}
|
||||
if m.options.AllowApiKeyAuth {
|
||||
requirements = append(requirements, map[string][]string{"ApiKeyAuth": {}})
|
||||
}
|
||||
if m.options.SuccessOptional {
|
||||
requirements = append([]map[string][]string{{}}, requirements...)
|
||||
}
|
||||
return requirements
|
||||
}
|
||||
|
||||
func setGinAuthentication(c *gin.Context, result authenticationResult) {
|
||||
c.Set("userID", result.UserID)
|
||||
c.Set("userIsAdmin", result.IsAdmin)
|
||||
c.Set("authenticationMethod", result.AuthenticationMethod)
|
||||
c.Set("authenticationTime", result.AuthenticationTime)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
@@ -35,7 +39,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)
|
||||
@@ -87,6 +94,41 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
})
|
||||
|
||||
t.Run("Huma decorator preserves JWT-only behavior and documentation", func(t *testing.T) {
|
||||
humaRouter := gin.New()
|
||||
api := httpapi.New(humaRouter, humaRouter.Group("/"))
|
||||
operation := huma.Operation{OperationID: "huma-protected", Method: http.MethodGet, Path: "/api/huma-protected"}
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api)(&operation)
|
||||
require.Equal(t, []map[string][]string{{"BearerAuth": {}}, {"SessionCookie": {}}}, operation.Security)
|
||||
httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil })
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil)
|
||||
request.Header.Set("X-API-Key", apiKeyToken)
|
||||
response := httptest.NewRecorder()
|
||||
humaRouter.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusForbidden, response.Code)
|
||||
require.JSONEq(t, `{"error":"API key authentication is not allowed for this endpoint"}`, response.Body.String())
|
||||
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/huma-protected", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+jwtToken)
|
||||
response = httptest.NewRecorder()
|
||||
humaRouter.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
})
|
||||
|
||||
t.Run("Huma admin decorator records admin authorization separately from security scopes", func(t *testing.T) {
|
||||
humaRouter := gin.New()
|
||||
api := httpapi.New(humaRouter, humaRouter.Group("/"))
|
||||
operation := huma.Operation{}
|
||||
authMiddleware.Huma(api)(&operation)
|
||||
require.Equal(t, true, operation.Extensions["x-pocket-id-admin-required"])
|
||||
for _, requirement := range operation.Security {
|
||||
for _, scopes := range requirement {
|
||||
require.Empty(t, scopes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User {
|
||||
|
||||
@@ -2,13 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -29,24 +26,6 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
svErr, ok := errors.AsType[binding.SliceValidationError](err)
|
||||
if ok {
|
||||
if errors.As(svErr[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// AppError with description
|
||||
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
|
||||
if ok {
|
||||
@@ -89,37 +68,3 @@ func errorResponseWithDescription(c *gin.Context, statusCode int, message string
|
||||
ErrorDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
var errorMessages []string
|
||||
|
||||
for _, ve := range validationErrors {
|
||||
fieldName := ve.Field()
|
||||
var errorMessage string
|
||||
switch ve.Tag() {
|
||||
case "required":
|
||||
errorMessage = fmt.Sprintf("%s is required", fieldName)
|
||||
case "email":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||
case "username":
|
||||
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "resource_uri":
|
||||
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
|
||||
case "min":
|
||||
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||
case "max":
|
||||
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, errorMessage)
|
||||
}
|
||||
|
||||
// Join all the error messages into a single string
|
||||
combinedErrors := strings.Join(errorMessages, ", ")
|
||||
|
||||
return combinedErrors
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
@@ -14,6 +16,20 @@ func NewFileSizeLimitMiddleware() *FileSizeLimitMiddleware {
|
||||
return &FileSizeLimitMiddleware{}
|
||||
}
|
||||
|
||||
// Huma returns a multipart size-limit middleware that preserves the existing error message
|
||||
func (m *FileSizeLimitMiddleware) Huma(api huma.API, maxSize int64) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
ginCtx := humagin.Unwrap(ctx)
|
||||
ginCtx.Request.Body = http.MaxBytesReader(ginCtx.Writer, ginCtx.Request.Body, maxSize)
|
||||
if err := ginCtx.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
fileError := &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||
_ = huma.WriteErr(api, ctx, fileError.HttpStatusCode(), fileError.Error())
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
|
||||
49
backend/internal/middleware/file_size_limit_test.go
Normal file
49
backend/internal/middleware/file_size_limit_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
func TestHumaFileSizeLimitMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := httpapi.New(router, router.Group("/"))
|
||||
operation := huma.Operation{OperationID: "multipart-overflow", Method: http.MethodPost, Path: "/api/upload"}
|
||||
operation.Middlewares = append(operation.Middlewares, NewFileSizeLimitMiddleware().Huma(api, 64))
|
||||
type uploadInput struct {
|
||||
RawBody huma.MultipartFormFiles[struct {
|
||||
File huma.FormFile `form:"file" required:"true"`
|
||||
}]
|
||||
}
|
||||
httpapi.Register(api, operation, func(context.Context, *uploadInput) (*struct{}, error) {
|
||||
t.Fatal("handler must not run after multipart overflow")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "large.bin")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(bytes.Repeat([]byte("x"), 256))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/upload", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, response.Code)
|
||||
require.JSONEq(t, `{"error":"The file can't be larger than 64 bytes"}`, response.Body.String())
|
||||
}
|
||||
@@ -60,7 +60,6 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok {
|
||||
_ = c.Error(&common.TokenInvalidError{})
|
||||
return "", false, "", time.Time{}, &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
|
||||
@@ -86,22 +88,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
|
||||
// Skip rate limiting for localhost and test environment
|
||||
// If the client ip is localhost the request comes from the frontend
|
||||
if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now
|
||||
allowed, retryAfter, err := svc.Allow(c.Request.Context(), ip)
|
||||
allowed, retryAfter, err := allowRequest(c.Request.Context(), svc, policy, ip)
|
||||
if err != nil {
|
||||
// Fail open so a limiter error does not turn away otherwise-valid traffic
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
// A cancelled context just means the client went away, so it is not worth logging
|
||||
slog.WarnContext(c.Request.Context(), "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err))
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -119,3 +107,53 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Huma returns a Huma middleware backed by the existing rate-limit service
|
||||
func (m *RateLimitMiddleware) Huma(api huma.API, policy string) func(huma.Context, func(huma.Context)) {
|
||||
if common.EnvConfig.DisableRateLimiting {
|
||||
return func(ctx huma.Context, next func(huma.Context)) { next(ctx) }
|
||||
}
|
||||
|
||||
svc := m.services[policy]
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if svc == nil {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
ginCtx := humagin.Unwrap(ctx)
|
||||
allowed, retryAfter, err := allowRequest(ctx.Context(), svc, policy, ginCtx.ClientIP())
|
||||
if err != nil {
|
||||
next(ctx)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
if retryAfter > 0 {
|
||||
ctx.SetHeader("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
|
||||
}
|
||||
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, (&common.TooManyRequestsError{}).Error())
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func allowRequest(ctx context.Context, svc *ratelimit.RateLimitService, policy, ip string) (bool, time.Duration, error) {
|
||||
// Skip rate limiting for localhost and test environment
|
||||
// If the client ip is localhost the request comes from the frontend
|
||||
if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now
|
||||
allowed, retryAfter, err := svc.Allow(ctx, ip)
|
||||
if err != nil {
|
||||
// Fail open so a limiter error does not turn away otherwise-valid traffic
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
// A cancelled context just means the client went away, so it is not worth logging
|
||||
slog.WarnContext(ctx, "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err))
|
||||
}
|
||||
return true, 0, err
|
||||
}
|
||||
return allowed, retryAfter, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
@@ -23,15 +25,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()
|
||||
@@ -145,3 +148,33 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaRateLimitMiddleware(t *testing.T) {
|
||||
originalEnvConfig := common.EnvConfig
|
||||
t.Cleanup(func() { common.EnvConfig = originalEnvConfig })
|
||||
common.EnvConfig.AppEnv = common.AppEnvProduction
|
||||
common.EnvConfig.DisableRateLimiting = false
|
||||
|
||||
const policy = "huma-test-limit"
|
||||
services := startRateLimitServices(t, RateLimitPolicy{Name: policy, Rate: 1, Per: time.Hour, Burst: 1})
|
||||
router := gin.New()
|
||||
require.NoError(t, router.SetTrustedProxies(nil))
|
||||
api := httpapi.New(router, router.Group("/"))
|
||||
operation := huma.Operation{OperationID: "huma-rate-limit", Method: http.MethodGet, Path: "/api/rate-limit"}
|
||||
operation.Middlewares = append(operation.Middlewares, NewRateLimitMiddleware(services).Huma(api, policy))
|
||||
httpapi.Register(api, operation, func(context.Context, *struct{}) (*struct{}, error) { return &struct{}{}, nil })
|
||||
|
||||
request := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/rate-limit", nil)
|
||||
req.RemoteAddr = net.JoinHostPort("203.0.113.10", "12345")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, req)
|
||||
return response
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusNoContent, request().Code)
|
||||
response := request()
|
||||
require.Equal(t, http.StatusTooManyRequests, response.Code)
|
||||
require.NotEmpty(t, response.Header().Get("Retry-After"))
|
||||
require.JSONEq(t, `{"error":"Too many requests"}`, response.Body.String())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||
type DateTime time.Time //nolint:recvcheck
|
||||
|
||||
// Schema documents the JSON representation rather than the underlying time.Time structure
|
||||
func (date DateTime) Schema(huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{Type: huma.TypeString, Format: "date-time"}
|
||||
}
|
||||
|
||||
func DateTimeFromString(str string) (DateTime, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, str)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
|
||||
@@ -106,37 +106,33 @@ func (h *authorizationHandler) authorize(c *gin.Context) {
|
||||
h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
|
||||
interactionID := c.Param("id")
|
||||
|
||||
interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, interactionSession)
|
||||
type interactionIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) completeInteraction(c *gin.Context) {
|
||||
interactionID := c.Param("id")
|
||||
authenticationTime, _ := c.Get("authenticationTime")
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
type completeInteractionInput struct {
|
||||
ID string `path:"id"`
|
||||
Body completeInteractionRequest
|
||||
}
|
||||
|
||||
var request completeInteractionRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
|
||||
return
|
||||
}
|
||||
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
|
||||
func (h *authorizationHandler) getInteractionSession(ctx context.Context, input *interactionIDInput) (*httpapi.BodyOutput[interactionSessionForUser], error) {
|
||||
interactionSession, err := h.authorizationService.getInteractionSession(ctx, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[interactionSessionForUser]{Body: interactionSession}, nil
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
func (h *authorizationHandler) completeInteraction(ctx context.Context, input *completeInteractionInput) (*httpapi.BodyOutput[completeInteractionResponse], error) {
|
||||
reauthenticationToken := ""
|
||||
if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil {
|
||||
reauthenticationToken = requestCookie.Value
|
||||
}
|
||||
response, err := h.authorizationService.completeInteractionStep(ctx, input.ID, httpapi.UserID(ctx), input.Body.Step, reauthenticationToken, httpapi.AuthenticationTime(ctx), requestMetaFromContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[completeInteractionResponse]{Body: response}, nil
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
|
||||
@@ -169,6 +165,10 @@ func requestMetaFromGin(c *gin.Context) requestMeta {
|
||||
}
|
||||
}
|
||||
|
||||
func requestMetaFromContext(ctx context.Context) requestMeta {
|
||||
return requestMeta{IPAddress: httpapi.ClientIP(ctx), UserAgent: httpapi.UserAgent(ctx)}
|
||||
}
|
||||
|
||||
func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string {
|
||||
params := make(map[string]string)
|
||||
for key, values := range requester.GetRequestForm() {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type deviceHandler struct {
|
||||
@@ -38,50 +40,38 @@ func (h *deviceHandler) authorizeDevice(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
|
||||
authenticationTime, _ := c.Get("authenticationTime")
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
type deviceCodeInput struct {
|
||||
Code string `query:"code" required:"true"`
|
||||
}
|
||||
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
return
|
||||
func (h *deviceHandler) verifyDeviceCode(ctx context.Context, input *deviceCodeInput) (*httpapi.EmptyOutput, error) {
|
||||
reauthenticationToken := ""
|
||||
if requestCookie, err := httpapi.Cookie(ctx, cookie.ReauthenticationTokenCookieName); err == nil {
|
||||
reauthenticationToken = requestCookie.Value
|
||||
}
|
||||
|
||||
err := h.deviceService.acceptDeviceCode(
|
||||
c.Request.Context(),
|
||||
userCode,
|
||||
c.GetString("userID"),
|
||||
c.GetString("authenticationMethod"),
|
||||
typedAuthenticationTime,
|
||||
ctx,
|
||||
input.Code,
|
||||
httpapi.UserID(ctx),
|
||||
httpapi.AuthenticationMethod(ctx),
|
||||
httpapi.AuthenticationTime(ctx),
|
||||
reauthenticationToken,
|
||||
requestMetaFromGin(c),
|
||||
requestMetaFromContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, fosite.ErrAccessDenied) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
|
||||
return
|
||||
return nil, &common.OidcAccessDeniedError{}
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
|
||||
func (h *deviceHandler) deviceCodeInfo(ctx context.Context, input *deviceCodeInput) (*httpapi.BodyOutput[dto.DeviceCodeInfoDto], error) {
|
||||
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(ctx, input.Code, httpapi.UserID(ctx))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||
return &httpapi.BodyOutput[dto.DeviceCodeInfoDto]{Body: *deviceCodeInfo}, nil
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (h *endSessionHandler) endSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
http.SetCookie(c.Writer, cookie.NewAccessTokenCookie(-1, ""))
|
||||
if callbackURL == "" {
|
||||
c.Redirect(http.StatusFound, h.baseURL+"/logout")
|
||||
return
|
||||
|
||||
@@ -6,16 +6,19 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
TokenBaseURL string
|
||||
Secret []byte
|
||||
BaseURL string
|
||||
TokenBaseURL string
|
||||
Secret []byte
|
||||
AllowInsecureCallbackURLs bool
|
||||
}
|
||||
|
||||
type TokenSigner interface {
|
||||
@@ -97,26 +100,139 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
|
||||
// RegisterRawRoutes mounts protocol endpoints that must retain direct Gin and Fosite response control
|
||||
func (m *Module) RegisterRawRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, api huma.API) {
|
||||
rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
|
||||
|
||||
apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
|
||||
apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction)
|
||||
|
||||
apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
|
||||
|
||||
apiGroup.POST("/oidc/token", m.tokenHandler.token)
|
||||
|
||||
apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo)
|
||||
apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo)
|
||||
|
||||
apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken)
|
||||
|
||||
apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
|
||||
apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
|
||||
|
||||
apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
|
||||
apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode)
|
||||
apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo)
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "authorize-get",
|
||||
Method: http.MethodGet,
|
||||
Path: "/authorize",
|
||||
Summary: "Authorize",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
}, http.StatusOK, http.StatusFound)
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "authorize-post",
|
||||
Method: http.MethodPost,
|
||||
Path: "/authorize",
|
||||
Summary: "Authorize",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
}, http.StatusOK, http.StatusFound)
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "pushed-authorization-request",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/par",
|
||||
Summary: "Create pushed authorization request",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCClientBasic": {}}},
|
||||
})
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/token",
|
||||
Summary: "Exchange an OIDC token",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCClientBasic": {}}},
|
||||
})
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-userinfo-get",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/userinfo",
|
||||
Summary: "Get OIDC user info",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCAccessToken": {}}},
|
||||
})
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-userinfo-post",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/userinfo",
|
||||
Summary: "Get OIDC user info",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCAccessToken": {}}},
|
||||
})
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-introspection",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/introspect",
|
||||
Summary: "Introspect an OIDC token",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCClientBasic": {}}},
|
||||
})
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-end-session-get",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/end-session",
|
||||
Summary: "End an OIDC session",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
}, http.StatusFound)
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-end-session-post",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/end-session",
|
||||
Summary: "End an OIDC session",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
}, http.StatusFound)
|
||||
|
||||
httpapi.AddRawOperation(api, huma.Operation{
|
||||
OperationID: "oidc-device-authorization",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/device/authorize",
|
||||
Summary: "Create device authorization",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
Security: []map[string][]string{{"OIDCClientBasic": {}}},
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterTypedRoutes mounts JSON interaction and device verification endpoints
|
||||
func (m *Module) RegisterTypedRoutes(api huma.API, browserAuth func(*huma.Operation)) {
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-interaction",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/interactions/{id}",
|
||||
Summary: "Get OIDC interaction",
|
||||
Tags: []string{"OIDC Interactions"},
|
||||
}, m.authorizationHandler.getInteractionSession)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "complete-oidc-interaction",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/interactions/{id}/complete",
|
||||
Summary: "Complete OIDC interaction",
|
||||
Tags: []string{"OIDC Interactions"},
|
||||
}, m.authorizationHandler.completeInteraction, browserAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "verify-oidc-device-code",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/oidc/device/verify",
|
||||
Summary: "Verify OIDC device code",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.deviceHandler.verifyDeviceCode, browserAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "get-oidc-device-info",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/oidc/device/info",
|
||||
Summary: "Get OIDC device code info",
|
||||
Tags: []string{"OIDC Protocol"},
|
||||
}, m.deviceHandler.deviceCodeInfo, browserAuth)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -106,13 +106,13 @@ func (s *OidcService) ListClients(ctx context.Context, name string, listRequestO
|
||||
}
|
||||
|
||||
// As allowedUserGroupsCount is not a column, we need to manually sort it
|
||||
if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
|
||||
if listRequestOptions.SortColumn == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
|
||||
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
|
||||
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
|
||||
Group("oidc_clients.id").
|
||||
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
|
||||
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.SortDirection)
|
||||
|
||||
response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
|
||||
response, err := utils.Paginate(listRequestOptions.Page, listRequestOptions.Limit, query, &clients)
|
||||
return clients, response, err
|
||||
}
|
||||
|
||||
@@ -211,6 +211,8 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
||||
}
|
||||
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
|
||||
dto.Normalize(input)
|
||||
|
||||
// Base fields
|
||||
client.Name = input.Name
|
||||
client.Description = input.Description
|
||||
@@ -612,10 +614,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
|
||||
// Handle custom sorting for lastUsedAt column
|
||||
var response utils.PaginationResponse
|
||||
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
|
||||
if listRequestOptions.SortColumn == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
|
||||
query = query.
|
||||
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
|
||||
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
|
||||
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.SortDirection + " NULLS LAST")
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
@@ -457,10 +458,11 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
description := "A test client description"
|
||||
name := norm.NFD.String("Tést Client")
|
||||
description := norm.NFD.String("A café client description")
|
||||
input := dto.OidcClientCreateDto{
|
||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||
Name: "Test Client",
|
||||
Name: name,
|
||||
Description: description,
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
},
|
||||
@@ -473,7 +475,8 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
|
||||
err = db.First(&fetched, "id = ?", client.ID).Error
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, fetched.Description)
|
||||
assert.Equal(t, description, fetched.Description)
|
||||
assert.Equal(t, norm.NFC.String(name), fetched.Name)
|
||||
assert.Equal(t, norm.NFC.String(description), fetched.Description)
|
||||
}
|
||||
|
||||
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
|
||||
@@ -513,7 +516,7 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update with a description
|
||||
description := "Updated description"
|
||||
description := norm.NFD.String("Updated café description")
|
||||
input := dto.OidcClientUpdateDto{
|
||||
Name: "Test Client",
|
||||
Description: description,
|
||||
@@ -527,7 +530,7 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
err = db.First(&fetched, "id = ?", client.ID).Error
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, fetched.Description)
|
||||
assert.Equal(t, description, fetched.Description)
|
||||
assert.Equal(t, norm.NFC.String(description), fetched.Description)
|
||||
|
||||
// Update to clear the description
|
||||
input.Description = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
|
||||
}
|
||||
|
||||
// As userCount is not a column we need to manually sort it
|
||||
if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
|
||||
if listRequestOptions.SortColumn == "userCount" && utils.IsValidSortDirection(listRequestOptions.SortDirection) {
|
||||
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
|
||||
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
|
||||
Group("user_groups.id").
|
||||
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
|
||||
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.SortDirection)
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type signUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
|
||||
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"`
|
||||
Token string `json:"token" required:"false"`
|
||||
}
|
||||
|
||||
func (d *signupTokenCreateDto) Resolve(huma.Context) []error {
|
||||
if dto.ValidateTTL(d.TTL) {
|
||||
return nil
|
||||
}
|
||||
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
|
||||
}
|
||||
|
||||
type signupTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
TTL utils.JSONDuration `json:"ttl" required:"true"`
|
||||
UsageLimit int `json:"usageLimit" required:"true" minimum:"1" maximum:"100"`
|
||||
UserGroupIDs []string `json:"userGroupIds" required:"false"`
|
||||
}
|
||||
|
||||
type signupTokenDto struct {
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
const defaultSignupTokenDuration = time.Hour
|
||||
|
||||
type userOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
Body dto.UserDto
|
||||
}
|
||||
|
||||
type signupInput struct {
|
||||
Body signUpDto
|
||||
}
|
||||
|
||||
type tokenCreateInput struct {
|
||||
Body signupTokenCreateDto
|
||||
}
|
||||
|
||||
type tokenIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
appConfig AppConfigProvider
|
||||
@@ -23,175 +40,73 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
|
||||
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
|
||||
func (h *handler) checkInitialAdminSetupAvailable(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
|
||||
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(ctx)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if setupCompleted {
|
||||
_ = c.Error(&common.SetupNotAvailableError{})
|
||||
return
|
||||
return nil, &common.SetupNotAvailableError{}
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// signUpInitialAdmin godoc
|
||||
// @Summary Sign up initial admin user
|
||||
// @Description Sign up and generate setup access token for initial admin user
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body signUpDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/signup/setup [post]
|
||||
func (h *handler) signUpInitialAdmin(c *gin.Context) {
|
||||
var input signUpDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input)
|
||||
func (h *handler) signUpInitialAdmin(ctx context.Context, input *signupInput) (*userOutput, error) {
|
||||
user, token, err := h.service.SignUpInitialAdmin(ctx, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return h.userOutput(user, token)
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
// @Summary Create signup token
|
||||
// @Description Create a new signup token that allows user registration
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body signupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} signupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (h *handler) createSignupToken(c *gin.Context) {
|
||||
var input signupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ttl := input.TTL.Duration
|
||||
func (h *handler) createSignupToken(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[signupTokenDto], error) {
|
||||
ttl := input.Body.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
token, err := h.service.CreateSignupToken(ctx, ttl, input.Body.UsageLimit, input.Body.UserGroupIDs)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tokenDto signupTokenDto
|
||||
err = dto.MapStruct(signupToken, &tokenDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output signupTokenDto
|
||||
if err := dto.MapStruct(token, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokenDto)
|
||||
return &httpapi.BodyOutput[signupTokenDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
// listSignupTokensHandler godoc
|
||||
// @Summary List signup tokens
|
||||
// @Description Get a paginated list of signup tokens
|
||||
// @Tags Users
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[signupTokenDto]
|
||||
// @Router /api/signup-tokens [get]
|
||||
func (h *handler) listSignupTokens(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
|
||||
func (h *handler) listSignupTokens(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[signupTokenDto]], error) {
|
||||
tokens, pagination, err := h.service.ListSignupTokens(ctx, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tokensDto []signupTokenDto
|
||||
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
var output []signupTokenDto
|
||||
if err := dto.MapStructList(tokens, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
|
||||
Data: tokensDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[signupTokenDto]]{Body: dto.Paginated[signupTokenDto]{Data: output, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// deleteSignupTokenHandler godoc
|
||||
// @Summary Delete signup token
|
||||
// @Description Delete a signup token by ID
|
||||
// @Tags Users
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/signup-tokens/{id} [delete]
|
||||
func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
tokenID := c.Param("id")
|
||||
|
||||
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (h *handler) deleteSignupToken(ctx context.Context, input *tokenIDInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := h.service.DeleteSignupToken(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// signupHandler godoc
|
||||
// @Summary Sign up
|
||||
// @Description Create a new user account
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body signUpDto true "User information"
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /api/signup [post]
|
||||
func (h *handler) signup(c *gin.Context) {
|
||||
var input signUpDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||
func (h *handler) signup(ctx context.Context, input *signupInput) (*userOutput, error) {
|
||||
user, accessToken, err := h.service.SignUp(ctx, input.Body, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
return h.userOutput(user, accessToken)
|
||||
}
|
||||
|
||||
func (h *handler) userOutput(user model.User, accessToken string) (*userOutput, error) {
|
||||
var output dto.UserDto
|
||||
if err := dto.MapStruct(user, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, accessToken)}, Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
@@ -50,11 +52,56 @@ func New(deps Dependencies) *Module {
|
||||
|
||||
// RegisterRoutes mounts the signup and signup-token management endpoints
|
||||
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken)
|
||||
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
|
||||
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
|
||||
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
|
||||
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
|
||||
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation), signupRateLimit func(huma.Context, func(huma.Context))) {
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-signup-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/signup-tokens",
|
||||
Summary: "Create signup token",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.createSignupToken, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "list-signup-tokens",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/signup-tokens",
|
||||
Summary: "List signup tokens",
|
||||
Tags: []string{"Users"},
|
||||
}, m.handler.listSignupTokens, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "delete-signup-token",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/signup-tokens/{id}",
|
||||
Summary: "Delete signup token",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.deleteSignupToken, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "signup",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/signup",
|
||||
Summary: "Sign up",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.signup, httpapi.WithMiddleware(signupRateLimit))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "check-initial-admin-setup",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/signup/setup",
|
||||
Summary: "Check initial admin setup availability",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.checkInitialAdminSetupAvailable)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "signup-initial-admin",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/signup/setup",
|
||||
Summary: "Sign up initial admin user",
|
||||
Tags: []string{"Users"},
|
||||
}, m.handler.signUpInitialAdmin)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
|
||||
c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true)
|
||||
func NewAccessTokenCookie(maxAgeInSeconds int, token string) *http.Cookie {
|
||||
return newCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/")
|
||||
}
|
||||
|
||||
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
|
||||
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
|
||||
func NewSessionIDCookie(maxAgeInSeconds int, sessionID string) *http.Cookie {
|
||||
return newCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/")
|
||||
}
|
||||
|
||||
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
|
||||
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
|
||||
func NewDeviceTokenCookie(deviceToken string) *http.Cookie {
|
||||
return newCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token")
|
||||
}
|
||||
|
||||
func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) {
|
||||
c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true)
|
||||
func NewReauthenticationTokenCookie(reauthenticationToken string) *http.Cookie {
|
||||
return newCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/")
|
||||
}
|
||||
|
||||
func newCookie(name, value string, maxAge int, path string) *http.Cookie {
|
||||
// SameSite remains unset to preserve the cookies emitted by the existing Gin helpers
|
||||
//nolint:gosec
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
MaxAge: maxAge,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,26 @@ func BuildFormPostCSP(nonce, redirectURI, scriptHash string) string {
|
||||
return buildCSP(nonce, []string{redirectURI}, []string{scriptHash})
|
||||
}
|
||||
|
||||
// BuildAPIDocsCSP allows the pinned Scalar bundle and the assets it creates
|
||||
func BuildAPIDocsCSP(nonce string) string {
|
||||
scriptSrc := "script-src 'self' https://cdn.jsdelivr.net"
|
||||
if nonce != "" {
|
||||
scriptSrc += " 'nonce-" + nonce + "'"
|
||||
}
|
||||
|
||||
return "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src * blob: data:; " +
|
||||
"font-src 'self' https://cdn.jsdelivr.net data:; " +
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
|
||||
"worker-src blob:; " +
|
||||
"connect-src 'self'; " +
|
||||
scriptSrc
|
||||
}
|
||||
|
||||
func buildCSP(nonce string, formActionExtra, scriptSrcExtra []string) string {
|
||||
formAction := "'self'"
|
||||
scriptSrc := "script-src 'self'"
|
||||
|
||||
@@ -3,17 +3,10 @@ package utils
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetCacheControlHeader sets the Cache-Control header for the response.
|
||||
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
|
||||
_, ok := ctx.GetQuery("skipCache")
|
||||
if !ok {
|
||||
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
|
||||
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
|
||||
ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds)
|
||||
}
|
||||
|
||||
func CacheControlValue(maxAge, staleWhileRevalidate time.Duration) string {
|
||||
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
|
||||
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
|
||||
return "public, max-age=" + maxAgeSeconds + ", stale-while-revalidate=" + staleWhileRevalidateSeconds
|
||||
}
|
||||
|
||||
118
backend/internal/utils/huma/api.go
Normal file
118
backend/internal/utils/huma/api.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
const scalarDocsHTML = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pocket ID API Reference</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script
|
||||
id="api-reference"
|
||||
data-url="/api/openapi.json"
|
||||
data-configuration='{"theme":"purple","darkMode":true,"layout":"modern","hiddenClients":["unirest"],"defaultHttpClient":{"targetKey":"shell","clientKey":"curl"}}'></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.62.5" integrity="sha384-jVBCKhcCfx34USN27x4iQK1SBNdL/HxKq3KuBAxTS4WPaP5w80K4fjpwB+DezJL5" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
var ginCompatibleJSONFormat = huma.Format{
|
||||
Marshal: func(w io.Writer, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
},
|
||||
Unmarshal: json.Unmarshal,
|
||||
}
|
||||
|
||||
// New creates the Huma API on the existing rate-limited Gin group
|
||||
func New(r *gin.Engine, group *gin.RouterGroup) huma.API {
|
||||
config := huma.DefaultConfig("Pocket ID API", common.Version)
|
||||
config.CreateHooks = nil
|
||||
config.DocsPath = ""
|
||||
config.OpenAPIPath = "/api/openapi"
|
||||
config.SchemasPath = "/api/schemas"
|
||||
config.AllowAdditionalPropertiesByDefault = true
|
||||
config.Security = nil
|
||||
config.Formats = map[string]huma.Format{
|
||||
"application/json": ginCompatibleJSONFormat,
|
||||
"json": ginCompatibleJSONFormat,
|
||||
}
|
||||
config.DefaultFormat = "application/json"
|
||||
config.OnAddOperation = append(config.OnAddOperation, rewriteValidationResponse)
|
||||
if common.EnvConfig.AppURL != "" {
|
||||
config.Servers = []*huma.Server{{URL: common.EnvConfig.AppURL}}
|
||||
}
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"BearerAuth": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
BearerFormat: "JWT",
|
||||
Description: "Pocket ID session JWT sent in the Authorization header",
|
||||
},
|
||||
"SessionCookie": {
|
||||
Type: "apiKey",
|
||||
In: "cookie",
|
||||
Name: cookie.AccessTokenCookieName,
|
||||
Description: "Pocket ID browser session cookie",
|
||||
},
|
||||
"ApiKeyAuth": {
|
||||
Type: "apiKey",
|
||||
In: "header",
|
||||
Name: "X-API-Key",
|
||||
Description: "Pocket ID API key",
|
||||
},
|
||||
"OIDCAccessToken": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
Description: "OIDC access token",
|
||||
},
|
||||
"OIDCClientBasic": {
|
||||
Type: "http",
|
||||
Scheme: "basic",
|
||||
Description: "OIDC client credentials",
|
||||
},
|
||||
}
|
||||
|
||||
humagin.MultipartMaxMemory = r.MaxMultipartMemory
|
||||
api := humagin.NewWithGroup(r, group, config)
|
||||
api.UseMiddleware(CaptureRequestContext)
|
||||
registerScalarDocs(group)
|
||||
return api
|
||||
}
|
||||
|
||||
func rewriteValidationResponse(_ *huma.OpenAPI, operation *huma.Operation) {
|
||||
response, ok := operation.Responses["422"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, exists := operation.Responses["400"]; !exists {
|
||||
operation.Responses["400"] = response
|
||||
}
|
||||
delete(operation.Responses, "422")
|
||||
}
|
||||
|
||||
func registerScalarDocs(group *gin.RouterGroup) {
|
||||
group.GET("/api/docs", func(ctx *gin.Context) {
|
||||
nonce := utils.GetCSPNonce(ctx)
|
||||
ctx.Header("Content-Security-Policy", utils.BuildAPIDocsCSP(nonce))
|
||||
ctx.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarDocsHTML))
|
||||
})
|
||||
}
|
||||
237
backend/internal/utils/huma/api_test.go
Normal file
237
backend/internal/utils/huma/api_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testInput struct {
|
||||
Body struct {
|
||||
Name string `json:"name" required:"true" minLength:"3"`
|
||||
}
|
||||
}
|
||||
|
||||
type testOutput struct {
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
type testCookieOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
}
|
||||
|
||||
type testStreamOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
Body func(huma.Context)
|
||||
}
|
||||
|
||||
type optionalBodyInput struct {
|
||||
Body *json.RawMessage `required:"false"`
|
||||
}
|
||||
|
||||
type testAppError struct{}
|
||||
|
||||
func (testAppError) Error() string { return "test error" }
|
||||
func (testAppError) Description() string { return "test description" }
|
||||
func (testAppError) HttpStatusCode() int { return http.StatusConflict }
|
||||
|
||||
type trackingReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *trackingReader) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestAPI(t *testing.T) (*gin.Engine, huma.API) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := New(router, router.Group("/"))
|
||||
return router, api
|
||||
}
|
||||
|
||||
func TestRequestAndErrorCompatibility(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
Register(api, huma.Operation{OperationID: "test-request", Method: http.MethodPost, Path: "/api/test"}, func(_ context.Context, input *testInput) (*testOutput, error) {
|
||||
return &testOutput{Body: map[string]string{"name": input.Body.Name}}, nil
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-app-error", Method: http.MethodGet, Path: "/api/test-error"}, func(context.Context, *struct{}) (*struct{}, error) {
|
||||
return nil, testAppError{}
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-unknown-error", Method: http.MethodGet, Path: "/api/test-unknown-error"}, func(context.Context, *struct{}) (*struct{}, error) {
|
||||
return nil, errors.New("private failure")
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-optional-body", Method: http.MethodPost, Path: "/api/test-optional-body", DefaultStatus: http.StatusNoContent}, func(_ context.Context, input *optionalBodyInput) (*struct{}, error) {
|
||||
require.Nil(t, input.Body)
|
||||
return &struct{}{}, nil
|
||||
})
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"Pocket ID","unknown":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Equal(t, "application/json", response.Header().Get("Content-Type"))
|
||||
require.JSONEq(t, `{"name":"Pocket ID"}`, response.Body.String())
|
||||
require.Empty(t, response.Header().Get("Link"))
|
||||
require.NotContains(t, response.Body.String(), "$schema")
|
||||
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", nil)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
|
||||
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
|
||||
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"x"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.JSONEq(t, `{"error":"Expected length >= 3"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-error", nil))
|
||||
require.Equal(t, http.StatusConflict, response.Code)
|
||||
require.JSONEq(t, `{"error":"Test error","error_description":"test description"}`, response.Body.String())
|
||||
require.Less(t, strings.Index(response.Body.String(), `"error"`), strings.Index(response.Body.String(), `"error_description"`))
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-unknown-error", nil))
|
||||
require.Equal(t, http.StatusInternalServerError, response.Code)
|
||||
require.JSONEq(t, `{"error":"Something went wrong"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-optional-body", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
}
|
||||
|
||||
func TestCookiesStreamingAndOpenAPI(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
Register(api, huma.Operation{OperationID: "test-cookies", Method: http.MethodPost, Path: "/api/test-cookies", DefaultStatus: http.StatusNoContent}, func(context.Context, *struct{}) (*testCookieOutput, error) {
|
||||
return &testCookieOutput{SetCookie: []http.Cookie{{Name: "one", Value: "1"}, {Name: "two", Value: "2"}}}, nil
|
||||
})
|
||||
|
||||
reader := &trackingReader{Reader: strings.NewReader("streamed")}
|
||||
Register(api, huma.Operation{OperationID: "test-stream", Method: http.MethodGet, Path: "/api/test-stream"}, func(context.Context, *struct{}) (*testStreamOutput, error) {
|
||||
return &testStreamOutput{ContentType: "text/plain", Body: func(ctx huma.Context) {
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(ctx.BodyWriter(), reader)
|
||||
}}, nil
|
||||
})
|
||||
AddRawOperation(api, huma.Operation{
|
||||
OperationID: "test-raw",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test-raw",
|
||||
Summary: "Raw test",
|
||||
Tags: []string{"Test"},
|
||||
})
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-cookies", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
require.Equal(t, []string{"one=1", "two=2"}, response.Header().Values("Set-Cookie"))
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-stream", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Equal(t, "text/plain", response.Header().Get("Content-Type"))
|
||||
require.Equal(t, "streamed", response.Body.String())
|
||||
require.True(t, reader.closed)
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Body.String(), `"/api/test-raw"`)
|
||||
require.NotContains(t, response.Body.String(), `"422"`)
|
||||
require.NotContains(t, response.Body.String(), `"$schema"`)
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
|
||||
require.Contains(t, response.Header().Get("Content-Security-Policy"), "worker-src blob:")
|
||||
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
|
||||
}
|
||||
|
||||
func TestRegisterAppliesDecoratorsInOrder(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
var order []string
|
||||
|
||||
first := func(operation *huma.Operation) {
|
||||
operation.Middlewares = append(operation.Middlewares, func(ctx huma.Context, next func(huma.Context)) {
|
||||
order = append(order, "first")
|
||||
next(ctx)
|
||||
})
|
||||
}
|
||||
second := func(ctx huma.Context, next func(huma.Context)) {
|
||||
order = append(order, "second")
|
||||
next(ctx)
|
||||
}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "test-decorator-order",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/test-decorator-order",
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, func(context.Context, *struct{}) (*struct{}, error) {
|
||||
order = append(order, "handler")
|
||||
return &struct{}{}, nil
|
||||
}, first, WithMiddleware(second))
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-decorator-order", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
require.Equal(t, []string{"first", "second", "handler"}, order)
|
||||
}
|
||||
|
||||
func TestRegisterPreservesBodyLimitConfiguration(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "test-default-body-limits",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test-default-body-limits",
|
||||
}, func(context.Context, *testInput) (*testOutput, error) {
|
||||
return &testOutput{}, nil
|
||||
})
|
||||
|
||||
defaultOperation := api.OpenAPI().Paths["/api/test-default-body-limits"].Post
|
||||
require.Equal(t, int64(1<<20), defaultOperation.MaxBodyBytes)
|
||||
require.Equal(t, 5*time.Second, defaultOperation.BodyReadTimeout)
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-default-body-limits", strings.NewReader(`{"name":"`+strings.Repeat("x", 1<<20)+`"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, response.Code)
|
||||
require.JSONEq(t, `{"error":"Request body is too large limit=1048576 bytes"}`, response.Body.String())
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "test-unlimited-body",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/test-unlimited-body",
|
||||
MaxBodyBytes: -1,
|
||||
BodyReadTimeout: -1,
|
||||
}, func(context.Context, *testInput) (*testOutput, error) {
|
||||
return &testOutput{}, nil
|
||||
})
|
||||
|
||||
unlimitedOperation := api.OpenAPI().Paths["/api/test-unlimited-body"].Post
|
||||
require.Equal(t, int64(-1), unlimitedOperation.MaxBodyBytes)
|
||||
require.Equal(t, time.Duration(-1), unlimitedOperation.BodyReadTimeout)
|
||||
}
|
||||
101
backend/internal/utils/huma/context.go
Normal file
101
backend/internal/utils/huma/context.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
)
|
||||
|
||||
type contextKey uint8
|
||||
|
||||
const (
|
||||
requestContextKey contextKey = iota
|
||||
clientIPContextKey
|
||||
userIDContextKey
|
||||
userIsAdminContextKey
|
||||
authenticationMethodContextKey
|
||||
authenticationTimeContextKey
|
||||
)
|
||||
|
||||
// CaptureRequestContext exposes trusted Gin request metadata to typed handlers
|
||||
func CaptureRequestContext(ctx huma.Context, next func(huma.Context)) {
|
||||
ginCtx := humagin.Unwrap(ctx)
|
||||
ctx = huma.WithValue(ctx, requestContextKey, ginCtx.Request)
|
||||
ctx = huma.WithValue(ctx, clientIPContextKey, ginCtx.ClientIP())
|
||||
next(ctx)
|
||||
}
|
||||
|
||||
// WithAuthentication adds the authenticated identity to a Huma request context
|
||||
func WithAuthentication(ctx huma.Context, userID string, isAdmin bool, method string, authenticationTime time.Time) huma.Context {
|
||||
ctx = huma.WithValue(ctx, userIDContextKey, userID)
|
||||
ctx = huma.WithValue(ctx, userIsAdminContextKey, isAdmin)
|
||||
ctx = huma.WithValue(ctx, authenticationMethodContextKey, method)
|
||||
return huma.WithValue(ctx, authenticationTimeContextKey, authenticationTime)
|
||||
}
|
||||
|
||||
// Request returns the underlying HTTP request for protocol handlers that require it
|
||||
func Request(ctx context.Context) *http.Request {
|
||||
request, _ := ctx.Value(requestContextKey).(*http.Request)
|
||||
return request
|
||||
}
|
||||
|
||||
// ClientIP returns the trusted client IP calculated by Gin
|
||||
func ClientIP(ctx context.Context) string {
|
||||
value, _ := ctx.Value(clientIPContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// UserAgent returns the request user agent
|
||||
func UserAgent(ctx context.Context) string {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
return request.UserAgent()
|
||||
}
|
||||
|
||||
// Cookie returns a dynamically named request cookie
|
||||
func Cookie(ctx context.Context, name string) (*http.Cookie, error) {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
return request.Cookie(name)
|
||||
}
|
||||
|
||||
// QueryPresent reports whether a query key was present regardless of its value
|
||||
func QueryPresent(ctx context.Context, name string) bool {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := request.URL.Query()[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// UserID returns the authenticated user ID
|
||||
func UserID(ctx context.Context) string {
|
||||
value, _ := ctx.Value(userIDContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the authenticated user is an administrator
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
value, _ := ctx.Value(userIsAdminContextKey).(bool)
|
||||
return value
|
||||
}
|
||||
|
||||
// AuthenticationMethod returns the session authentication method
|
||||
func AuthenticationMethod(ctx context.Context) string {
|
||||
value, _ := ctx.Value(authenticationMethodContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// AuthenticationTime returns the session authentication time
|
||||
func AuthenticationTime(ctx context.Context) time.Time {
|
||||
value, _ := ctx.Value(authenticationTimeContextKey).(time.Time)
|
||||
return value
|
||||
}
|
||||
98
backend/internal/utils/huma/errors.go
Normal file
98
backend/internal/utils/huma/errors.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type apiError struct {
|
||||
status int
|
||||
Message string `json:"error"`
|
||||
Description string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
huma.NewError = newHumaError
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.Message }
|
||||
func (e *apiError) GetStatus() int { return e.status }
|
||||
|
||||
func (e *apiError) ContentType(contentType string) string {
|
||||
if contentType == "application/json" {
|
||||
return "application/json; charset=utf-8"
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
func newHumaError(status int, message string, errs ...error) huma.StatusError {
|
||||
if status == http.StatusUnprocessableEntity {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
messages := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
var detailer huma.ErrorDetailer
|
||||
if errors.As(err, &detailer) {
|
||||
messages = append(messages, detailer.ErrorDetail().Message)
|
||||
continue
|
||||
}
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
message = strings.Join(messages, ", ")
|
||||
}
|
||||
|
||||
return &apiError{status: status, Message: capitalize(message)}
|
||||
}
|
||||
|
||||
func capitalize(message string) string {
|
||||
if message == "" {
|
||||
return message
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(message)
|
||||
return string(unicode.ToUpper(r)) + message[size:]
|
||||
}
|
||||
|
||||
func mapError(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &apiError{status: http.StatusNotFound, Message: "Record not found"}
|
||||
}
|
||||
|
||||
var appDescriptionError common.AppErrorDescription
|
||||
if errors.As(err, &appDescriptionError) {
|
||||
return &apiError{
|
||||
status: appDescriptionError.HttpStatusCode(),
|
||||
Message: capitalize(appDescriptionError.Error()),
|
||||
Description: appDescriptionError.Description(),
|
||||
}
|
||||
}
|
||||
|
||||
var appError common.AppError
|
||||
if errors.As(err, &appError) {
|
||||
return &apiError{status: appError.HttpStatusCode(), Message: capitalize(appError.Error())}
|
||||
}
|
||||
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) {
|
||||
return &apiError{status: http.StatusRequestEntityTooLarge, Message: "The request body is too large"}
|
||||
}
|
||||
|
||||
slog.ErrorContext(ctx, "Unhandled API error", slog.Any("error", err))
|
||||
return &apiError{status: http.StatusInternalServerError, Message: "Something went wrong"}
|
||||
}
|
||||
21
backend/internal/utils/huma/raw.go
Normal file
21
backend/internal/utils/huma/raw.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// AddRawOperation documents a Gin endpoint that must retain direct response control
|
||||
func AddRawOperation(api huma.API, operation huma.Operation, statuses ...int) {
|
||||
if len(statuses) == 0 {
|
||||
statuses = []int{http.StatusOK}
|
||||
}
|
||||
responses := make(map[string]*huma.Response, len(statuses))
|
||||
for _, status := range statuses {
|
||||
responses[strconv.Itoa(status)] = &huma.Response{Description: http.StatusText(status)}
|
||||
}
|
||||
operation.Responses = responses
|
||||
api.OpenAPI().AddOperation(&operation)
|
||||
}
|
||||
29
backend/internal/utils/huma/register.go
Normal file
29
backend/internal/utils/huma/register.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// WithMiddleware appends operation middleware at the point the decorator is applied
|
||||
func WithMiddleware(middleware func(huma.Context, func(huma.Context))) func(*huma.Operation) {
|
||||
return func(operation *huma.Operation) {
|
||||
operation.Middlewares = append(operation.Middlewares, middleware)
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a typed operation while preserving Pocket ID error and body-reading behavior
|
||||
func Register[I, O any](api huma.API, operation huma.Operation, handler func(context.Context, *I) (*O, error), decorators ...func(*huma.Operation)) {
|
||||
for _, decorator := range decorators {
|
||||
decorator(&operation)
|
||||
}
|
||||
|
||||
huma.Register(api, operation, func(ctx context.Context, input *I) (*O, error) {
|
||||
output, err := handler(ctx, input)
|
||||
if err != nil {
|
||||
return nil, mapError(ctx, err)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
19
backend/internal/utils/huma/types.go
Normal file
19
backend/internal/utils/huma/types.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package humautils
|
||||
|
||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
// EmptyInput represents an operation without path, query, header, or body input
|
||||
type EmptyInput struct{}
|
||||
|
||||
// EmptyOutput represents an operation without a response body or headers
|
||||
type EmptyOutput struct{}
|
||||
|
||||
// BodyOutput wraps a typed response body for Huma
|
||||
type BodyOutput[T any] struct {
|
||||
Body T
|
||||
}
|
||||
|
||||
// ListInput exposes the shared list query parameters to Huma
|
||||
type ListInput struct {
|
||||
utils.ListRequestOptions
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user